ui: basic map scroller

This commit is contained in:
Ilia Denisov
2026-03-06 23:29:06 +02:00
committed by GitHub
parent 29d188969b
commit 1de621c743
68 changed files with 9861 additions and 118 deletions
+326
View File
@@ -0,0 +1,326 @@
package world
import (
"image"
"image/color"
"github.com/fogleman/gg"
)
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
//
// The renderer is responsible for all torus logic, viewport/margin logic,
// coordinate projection, and primitive duplication. This interface only accepts
// final canvas pixel coordinates and exposes the minimum drawing operations
// needed to build and render paths.
//
// AddPoint, AddLine, and AddCircle append geometry to the current path.
// They do not render by themselves. The caller must finalize the path by
// calling Stroke or Fill.
//
// Save and Restore are intended for temporary local state changes such as
// clipping, colors, line width, or dash settings. After Restore, the outer
// drawing state must be visible again.
type PrimitiveDrawer interface {
// Save stores the current drawing state.
Save()
// Restore restores the most recently saved drawing state.
Restore()
// ResetClip clears the current clipping region completely.
ResetClip()
// ClipRect intersects the current clipping region with the given rectangle
// in canvas pixel coordinates.
ClipRect(x, y, w, h float64)
// SetStrokeColor sets the color used by Stroke.
SetStrokeColor(c color.Color)
// SetFillColor sets the color used by Fill.
SetFillColor(c color.Color)
// SetLineWidth sets the line width used by Stroke.
SetLineWidth(width float64)
// SetDash sets the dash pattern used by Stroke.
// Passing no values clears the current dash pattern.
SetDash(dashes ...float64)
// SetDashOffset sets the dash phase used by Stroke.
SetDashOffset(offset float64)
// AddPoint appends a point marker centered at (x, y) with radius r
// to the current path in canvas pixel coordinates.
AddPoint(x, y, r float64)
// AddLine appends a line segment to the current path in canvas pixel coordinates.
AddLine(x1, y1, x2, y2 float64)
// AddCircle appends a circle to the current path in canvas pixel coordinates.
AddCircle(cx, cy, r float64)
// Stroke renders the current path using the current stroke state.
Stroke()
// Fill renders the current path using the current fill state.
Fill()
// CopyShift shifts the current backing image by (dx, dy) in canvas pixels.
// dx > 0 shifts the image to the right, dy > 0 shifts the image down.
// Newly exposed areas are cleared to transparent.
CopyShift(dx, dy int)
// ClearAll clears the entire backing canvas to transparent.
// Must NOT affect the current clip state.
ClearAll()
// ClearRect clears a pixel rectangle to transparent.
// Must NOT affect the current clip state.
ClearRect(x, y, w, h int)
}
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop
// do not restore clip masks the way this package expects.
type ggClipRect struct {
x, y float64
w, h float64
}
// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context.
//
// It intentionally does not perform any world logic. It only forwards already
// projected canvas coordinates to gg while additionally maintaining a clip stack
// compatible with this package's Save/Restore contract.
type GGDrawer struct {
DC *gg.Context
clips []ggClipRect
clipStack [][]ggClipRect
// scratch is a reusable buffer for CopyShift to avoid allocations.
scratch *image.RGBA
}
// Save stores the current gg state and the current logical clip stack.
func (d *GGDrawer) Save() {
d.DC.Push()
snapshot := append([]ggClipRect(nil), d.clips...)
d.clipStack = append(d.clipStack, snapshot)
}
// Restore restores the previous gg state and rebuilds the outer clip state.
//
// gg.Context.Pop restores most state from the stack, but its clip mask handling
// does not match this package's expected Save/Restore semantics. To preserve the
// contract, GGDrawer explicitly resets the clip and replays the previously saved
// clip rectangles after Pop.
func (d *GGDrawer) Restore() {
if len(d.clipStack) == 0 {
panic("GGDrawer: Restore without matching Save")
}
snapshot := d.clipStack[len(d.clipStack)-1]
d.clipStack = d.clipStack[:len(d.clipStack)-1]
d.DC.Pop()
d.clips = append([]ggClipRect(nil), snapshot...)
d.DC.ResetClip()
for _, clip := range d.clips {
d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h)
d.DC.Clip()
}
}
// ResetClip clears the current clipping region and the logical clip stack
// for the active state frame.
func (d *GGDrawer) ResetClip() {
d.DC.ResetClip()
d.clips = nil
}
// ClipRect intersects the current clipping region with the given rectangle
// and records it so the clip can be reconstructed after Restore.
func (d *GGDrawer) ClipRect(x, y, w, h float64) {
d.DC.DrawRectangle(x, y, w, h)
d.DC.Clip()
d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h})
}
// SetStrokeColor sets the stroke color by installing a solid stroke pattern.
func (d *GGDrawer) SetStrokeColor(c color.Color) {
d.DC.SetStrokeStyle(gg.NewSolidPattern(c))
}
// SetFillColor sets the fill color by installing a solid fill pattern.
func (d *GGDrawer) SetFillColor(c color.Color) {
d.DC.SetFillStyle(gg.NewSolidPattern(c))
}
// SetLineWidth sets the line width used for stroking.
func (d *GGDrawer) SetLineWidth(width float64) {
d.DC.SetLineWidth(width)
}
// SetDash sets the dash pattern used for stroking.
func (d *GGDrawer) SetDash(dashes ...float64) {
d.DC.SetDash(dashes...)
}
// SetDashOffset sets the dash phase used for stroking.
func (d *GGDrawer) SetDashOffset(offset float64) {
d.DC.SetDashOffset(offset)
}
// AddPoint appends a point marker to the current path.
func (d *GGDrawer) AddPoint(x, y, r float64) {
d.DC.DrawPoint(x, y, r)
}
// AddLine appends a line segment to the current path.
func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) {
d.DC.DrawLine(x1, y1, x2, y2)
}
// AddCircle appends a circle to the current path.
func (d *GGDrawer) AddCircle(cx, cy, r float64) {
d.DC.DrawCircle(cx, cy, r)
}
// Stroke renders the current path using the current stroke state.
func (d *GGDrawer) Stroke() {
d.DC.Stroke()
}
// Fill renders the current path using the current fill state.
func (d *GGDrawer) Fill() {
d.DC.Fill()
}
// CopyShift shifts the backing RGBA image by (dx, dy) pixels.
// It clears newly exposed areas to transparent.
func (d *GGDrawer) CopyShift(dx, dy int) {
if dx == 0 && dy == 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.CopyShift: backing image is not *image.RGBA")
}
b := img.Bounds()
w := b.Dx()
h := b.Dy()
if w <= 0 || h <= 0 {
return
}
adx := abs(dx)
ady := abs(dy)
if adx >= w || ady >= h {
// Everything shifts out of bounds => just clear.
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Prepare scratch with the same bounds.
if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h {
d.scratch = image.NewRGBA(b)
} else {
// Clear scratch to transparent.
for i := range d.scratch.Pix {
d.scratch.Pix[i] = 0
}
}
// Compute source/destination rectangles.
dstX0 := 0
dstY0 := 0
srcX0 := 0
srcY0 := 0
if dx > 0 {
dstX0 = dx
} else {
srcX0 = -dx
}
if dy > 0 {
dstY0 = dy
} else {
srcY0 = -dy
}
copyW := w - max(dstX0, srcX0)
copyH := h - max(dstY0, srcY0)
if copyW <= 0 || copyH <= 0 {
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Copy row-by-row (RGBA, 4 bytes per pixel).
for row := 0; row < copyH; row++ {
srcY := srcY0 + row
dstY := dstY0 + row
srcOff := srcY*img.Stride + srcX0*4
dstOff := dstY*d.scratch.Stride + dstX0*4
n := copyW * 4
copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n])
}
// Swap buffers by copying scratch into img.
// (We keep img pointer stable for gg.Context.)
copy(img.Pix, d.scratch.Pix)
}
// ClearAll clears the whole RGBA backing image to transparent.
// It does not touch gg clip state.
func (d *GGDrawer) ClearAll() {
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearAll: backing image is not *image.RGBA")
}
for i := range img.Pix {
img.Pix[i] = 0
}
}
// ClearRect clears a region to transparent. It does not touch gg clip state.
// Rectangle is clamped to the image bounds.
func (d *GGDrawer) ClearRect(x, y, w, h int) {
if w <= 0 || h <= 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearRect: backing image is not *image.RGBA")
}
b := img.Bounds()
x0 := max(x, b.Min.X)
y0 := max(y, b.Min.Y)
x1 := min(x+w, b.Max.X)
y1 := min(y+h, b.Max.Y)
if x0 >= x1 || y0 >= y1 {
return
}
// Zero rows.
for yy := y0; yy < y1; yy++ {
off := yy*img.Stride + x0*4
n := (x1 - x0) * 4
for i := 0; i < n; i++ {
img.Pix[off+i] = 0
}
}
}
+276
View File
@@ -0,0 +1,276 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
)
func hasAnyNonTransparentPixel(img image.Image) bool {
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a != 0 {
return true
}
}
}
return false
}
func pixelHasAlpha(img image.Image, x, y int) bool {
_, _, _, a := img.At(x, y).RGBA()
return a != 0
}
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetStrokeColor(color.RGBA{R: 255, A: 255})
drawer.SetLineWidth(2)
drawer.SetDash(4, 2)
drawer.SetDashOffset(1)
drawer.AddLine(4, 16, 28, 16)
drawer.Stroke()
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
}
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetFillColor(color.RGBA{G: 255, A: 255})
drawer.AddCircle(16, 16, 6)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
drawer.AddPoint(16, 16, 3)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
drawer.Restore()
img := dc.Image()
require.True(t, pixelHasAlpha(img, 5, 16))
require.False(t, pixelHasAlpha(img, 15, 16))
}
func TestGGDrawerResetClipClearsClip(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.ClipRect(0, 0, 10, 32)
drawer.ResetClip()
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
func TestGGDrawerClearRect_ClearsPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(10, 10)
dr := &GGDrawer{DC: dc}
// Draw something everywhere.
dr.SetFillColor(color.RGBA{R: 255, A: 255})
dr.ClipRect(0, 0, 10, 10)
dr.AddCircle(5, 5, 5)
dr.Fill()
dr.ResetClip()
// Clear a 2x2 rect at (1,1)
dr.ClearRect(1, 1, 2, 2)
img := dc.Image()
_, _, _, a := img.At(1, 1).RGBA()
require.Equal(t, uint32(0), a)
// Pixel outside should remain non-zero alpha.
_, _, _, a2 := img.At(5, 5).RGBA()
require.NotEqual(t, uint32(0), a2)
}
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.Restore()
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.ClipRect(0, 0, 20, 32)
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.Restore()
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
img := dc.Image()
require.True(t, pixelHasAlpha(img, 15, 16))
require.False(t, pixelHasAlpha(img, 25, 16))
}
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.Save()
d.ClipRect(1, 2, 30, 40)
d.SetStrokeColor(color.RGBA{R: 10, G: 20, B: 30, A: 255})
d.SetFillColor(color.RGBA{R: 40, G: 50, B: 60, A: 255})
d.SetLineWidth(3)
d.SetDash(5, 6)
d.SetDashOffset(7)
d.AddLine(10, 11, 12, 13)
d.Stroke()
d.Restore()
requireDrawerCommandNames(t, d,
"Save",
"ClipRect",
"SetStrokeColor",
"SetFillColor",
"SetLineWidth",
"SetDash",
"SetDashOffset",
"AddLine",
"Stroke",
"Restore",
)
cmd := requireDrawerSingleCommand(t, d, "AddLine")
requireCommandArgs(t, cmd, 10, 11, 12, 13)
requireCommandLineWidth(t, cmd, 3)
requireCommandDashes(t, cmd, 5, 6)
requireCommandDashOffset(t, cmd, 7)
requireCommandClipRects(t, cmd, fakeClipRect{X: 1, Y: 2, W: 30, H: 40})
require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, cmd.StrokeColor)
require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor)
}
func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
require.Panics(t, func() {
d.Restore()
})
}
func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.SetLineWidth(1)
d.Save()
d.SetLineWidth(9)
d.ClipRect(1, 2, 3, 4)
d.Restore()
state := d.CurrentState()
require.Equal(t, 1.0, state.LineWidth)
require.Empty(t, state.Clips)
require.Equal(t, 0, d.SaveDepth())
}
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.SetLineWidth(4)
d.ClipRect(1, 2, 3, 4)
d.ResetClip()
state := d.CurrentState()
require.Equal(t, 4.0, state.LineWidth)
require.Empty(t, state.Clips)
}
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(10, 10)
drawer := &GGDrawer{DC: dc}
// Draw a single filled point at (1,1).
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
drawer.AddPoint(1, 1, 1)
drawer.Fill()
// Shift image right by 2 and down by 3.
drawer.CopyShift(2, 3)
img := dc.Image()
// The old pixel near (1,1) should now be present near (3,4).
// We check alpha only to avoid depending on exact blending.
_, _, _, a := img.At(3, 4).RGBA()
require.NotEqual(t, uint32(0), a)
// A pixel in the newly exposed top-left area should be transparent.
_, _, _, a2 := img.At(0, 0).RGBA()
require.Equal(t, uint32(0), a2)
}
+95
View File
@@ -0,0 +1,95 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// requireDrawerCommandNames asserts the exact command sequence recorded
// by fakePrimitiveDrawer.
func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) {
t.Helper()
require.Equal(t, want, d.CommandNames())
}
// requireDrawerCommandCount asserts the number of recorded commands.
func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) {
t.Helper()
require.Len(t, d.Commands(), want)
}
// requireDrawerCommandAt returns the command at the specified index.
func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmds := d.Commands()
require.GreaterOrEqual(t, index, 0)
require.Less(t, index, len(cmds))
return cmds[index]
}
// requireDrawerSingleCommand returns the only command with the given name.
func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand {
t.Helper()
cmds := d.CommandsByName(name)
require.Len(t, cmds, 1)
return cmds[0]
}
// requireCommandName asserts the command name.
func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) {
t.Helper()
require.Equal(t, want, cmd.Name)
}
// requireCommandArgs asserts the exact float arguments.
func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Args)
}
// requireCommandArgsInDelta asserts the float arguments with tolerance.
func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) {
t.Helper()
require.Len(t, cmd.Args, len(want))
for i := range want {
require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i)
}
}
// requireCommandClipRects asserts the clip stack snapshot attached to the command.
func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) {
t.Helper()
require.Equal(t, want, cmd.Clips)
}
// requireCommandLineWidth asserts the line width snapshot attached to the command.
func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.LineWidth)
}
// requireCommandDashes asserts the dash snapshot attached to the command.
func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Dashes)
}
// requireCommandDashOffset asserts the dash offset snapshot attached to the command.
func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.DashOffset)
}
+240
View File
@@ -0,0 +1,240 @@
package world
import (
"fmt"
"image/color"
)
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
type fakeClipRect struct {
X, Y float64
W, H float64
}
// fakeDrawerState stores the active fake drawing state.
// The state is copied on Save and restored on Restore.
type fakeDrawerState struct {
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// clone returns a deep copy of the state.
func (s fakeDrawerState) clone() fakeDrawerState {
out := s
out.Dashes = append([]float64(nil), s.Dashes...)
out.Clips = append([]fakeClipRect(nil), s.Clips...)
return out
}
// fakeDrawerCommand is one recorded drawer call together with a snapshot
// of the active fake drawing state at the moment of the call.
type fakeDrawerCommand struct {
Name string
Args []float64
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// String returns a compact debug representation useful in assertion failures.
func (c fakeDrawerCommand) String() string {
return fmt.Sprintf(
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
c.Name,
c.Args,
c.StrokeColor,
c.FillColor,
c.LineWidth,
c.Dashes,
c.DashOffset,
c.Clips,
)
}
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
// It records all calls and emulates stateful behavior, including nested
// Save/Restore and clip reset semantics.
type fakePrimitiveDrawer struct {
commands []fakeDrawerCommand
state fakeDrawerState
stack []fakeDrawerState
}
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
// rgbaColor converts any color.Color into a comparable RGBA value.
func rgbaColor(c color.Color) color.RGBA {
if c == nil {
return color.RGBA{}
}
return color.RGBAModel.Convert(c).(color.RGBA)
}
// snapshotCommand records one command together with the current state snapshot.
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
cmd := fakeDrawerCommand{
Name: name,
Args: append([]float64(nil), args...),
StrokeColor: d.state.StrokeColor,
FillColor: d.state.FillColor,
LineWidth: d.state.LineWidth,
Dashes: append([]float64(nil), d.state.Dashes...),
DashOffset: d.state.DashOffset,
Clips: append([]fakeClipRect(nil), d.state.Clips...),
}
d.commands = append(d.commands, cmd)
}
// Save stores the current fake state.
func (d *fakePrimitiveDrawer) Save() {
d.stack = append(d.stack, d.state.clone())
d.snapshotCommand("Save")
}
// Restore restores the most recently saved fake state.
func (d *fakePrimitiveDrawer) Restore() {
if len(d.stack) == 0 {
panic("fakePrimitiveDrawer: Restore without matching Save")
}
d.state = d.stack[len(d.stack)-1]
d.stack = d.stack[:len(d.stack)-1]
d.snapshotCommand("Restore")
}
// ResetClip clears the current fake clip stack.
func (d *fakePrimitiveDrawer) ResetClip() {
d.state.Clips = nil
d.snapshotCommand("ResetClip")
}
// ClipRect appends one clip rectangle to the current fake state.
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
d.snapshotCommand("ClipRect", x, y, w, h)
}
// SetStrokeColor sets the current fake stroke color.
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
d.state.StrokeColor = rgbaColor(c)
d.snapshotCommand("SetStrokeColor")
}
// SetFillColor sets the current fake fill color.
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
d.state.FillColor = rgbaColor(c)
d.snapshotCommand("SetFillColor")
}
// SetLineWidth sets the current fake line width.
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
d.state.LineWidth = width
d.snapshotCommand("SetLineWidth", width)
}
// SetDash sets the current fake dash pattern.
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
d.state.Dashes = append([]float64(nil), dashes...)
d.snapshotCommand("SetDash", dashes...)
}
// SetDashOffset sets the current fake dash offset.
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
d.state.DashOffset = offset
d.snapshotCommand("SetDashOffset", offset)
}
// AddPoint records a point path append command.
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
d.snapshotCommand("AddPoint", x, y, r)
}
// AddLine records a line path append command.
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
d.snapshotCommand("AddLine", x1, y1, x2, y2)
}
// AddCircle records a circle path append command.
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
d.snapshotCommand("AddCircle", cx, cy, r)
}
// Stroke records a stroke finalization command.
func (d *fakePrimitiveDrawer) Stroke() {
d.snapshotCommand("Stroke")
}
// Fill records a fill finalization command.
func (d *fakePrimitiveDrawer) Fill() {
d.snapshotCommand("Fill")
}
// Commands returns a defensive copy of the recorded command log.
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
out := make([]fakeDrawerCommand, len(d.commands))
copy(out, d.commands)
return out
}
// CommandNames returns only command names in call order.
func (d *fakePrimitiveDrawer) CommandNames() []string {
out := make([]string, 0, len(d.commands))
for _, cmd := range d.commands {
out = append(out, cmd.Name)
}
return out
}
// CommandsByName returns all commands with the given name.
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
var out []fakeDrawerCommand
for _, cmd := range d.commands {
if cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
// LastCommand returns the last recorded command and whether it exists.
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
if len(d.commands) == 0 {
return fakeDrawerCommand{}, false
}
return d.commands[len(d.commands)-1], true
}
// CurrentState returns a defensive copy of the current fake state.
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
return d.state.clone()
}
// SaveDepth returns the current Save/Restore nesting depth.
func (d *fakePrimitiveDrawer) SaveDepth() int {
return len(d.stack)
}
// ResetLog clears only the command log and keeps the current state intact.
func (d *fakePrimitiveDrawer) ResetLog() {
d.commands = nil
}
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
}
func (d *fakePrimitiveDrawer) ClearAll() {
d.snapshotCommand("ClearAll")
}
func (d *fakePrimitiveDrawer) ClearRect(x, y, w, h int) {
d.snapshotCommand("ClearRect", float64(x), float64(y), float64(w), float64(h))
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
package world
import (
"github.com/google/uuid"
)
// MapItem is the common interface implemented by all world primitives.
type MapItem interface {
ID() uuid.UUID
}
// Point is a point primitive in fixed-point world coordinates.
type Point struct {
Id uuid.UUID
X, Y int
}
// Line is a line segment primitive in fixed-point world coordinates.
type Line struct {
Id uuid.UUID
X1, Y1 int
X2, Y2 int
}
// Circle is a circle primitive in fixed-point world coordinates.
type Circle struct {
Id uuid.UUID
X, Y int
Radius int
}
// ID returns the point identifier.
func (p Point) ID() uuid.UUID { return p.Id }
// ID returns the line identifier.
func (l Line) ID() uuid.UUID { return l.Id }
// ID returns the circle identifier.
func (c Circle) ID() uuid.UUID { return c.Id }
// MinX returns the minimum X endpoint coordinate of the line.
func (l Line) MinX() int { return min(l.X1, l.X2) }
// MaxX returns the maximum X endpoint coordinate of the line.
func (l Line) MaxX() int { return max(l.X1, l.X2) }
// MinY returns the minimum Y endpoint coordinate of the line.
func (l Line) MinY() int { return min(l.Y1, l.Y2) }
// MaxY returns the maximum Y endpoint coordinate of the line.
func (l Line) MaxY() int { return max(l.Y1, l.Y2) }
// MinX returns the minimum X coordinate of the circle bbox.
func (c Circle) MinX() int { return c.X - c.Radius }
// MaxX returns the maximum X coordinate of the circle bbox.
func (c Circle) MaxX() int { return c.X + c.Radius }
// MinY returns the minimum Y coordinate of the circle bbox.
func (c Circle) MinY() int { return c.Y - c.Radius }
// MaxY returns the maximum Y coordinate of the circle bbox.
func (c Circle) MaxY() int { return c.Y + c.Radius }
+74
View File
@@ -0,0 +1,74 @@
package world
import (
"testing"
"github.com/google/uuid"
)
func TestPrimitiveIDs(t *testing.T) {
t.Parallel()
id1 := uuid.New()
id2 := uuid.New()
id3 := uuid.New()
p := Point{Id: id1}
l := Line{Id: id2}
c := Circle{Id: id3}
if got := p.ID(); got != id1 {
t.Fatalf("Point.ID() = %v, want %v", got, id1)
}
if got := l.ID(); got != id2 {
t.Fatalf("Line.ID() = %v, want %v", got, id2)
}
if got := c.ID(); got != id3 {
t.Fatalf("Circle.ID() = %v, want %v", got, id3)
}
}
func TestLineMinMax(t *testing.T) {
t.Parallel()
l := Line{
X1: 7000, Y1: 2000,
X2: 1000, Y2: 9000,
}
if got := l.MinX(); got != 1000 {
t.Fatalf("Line.MinX() = %d, want 1000", got)
}
if got := l.MaxX(); got != 7000 {
t.Fatalf("Line.MaxX() = %d, want 7000", got)
}
if got := l.MinY(); got != 2000 {
t.Fatalf("Line.MinY() = %d, want 2000", got)
}
if got := l.MaxY(); got != 9000 {
t.Fatalf("Line.MaxY() = %d, want 9000", got)
}
}
func TestCircleBounds(t *testing.T) {
t.Parallel()
c := Circle{
X: 4000,
Y: 7000,
Radius: 1500,
}
if got := c.MinX(); got != 2500 {
t.Fatalf("Circle.MinX() = %d, want 2500", got)
}
if got := c.MaxX(); got != 5500 {
t.Fatalf("Circle.MaxX() = %d, want 5500", got)
}
if got := c.MinY(); got != 5500 {
t.Fatalf("Circle.MinY() = %d, want 5500", got)
}
if got := c.MaxY(); got != 8500 {
t.Fatalf("Circle.MaxY() = %d, want 8500", got)
}
}
+473
View File
@@ -0,0 +1,473 @@
package world
import (
"errors"
"time"
)
// RenderLayer identifies one drawing pass.
type RenderLayer int
const (
RenderLayerPoints RenderLayer = iota
RenderLayerCircles
RenderLayerLines
)
// RenderOptions controls which layers are rendered and their order.
// If Layers is empty, the default order is: Points, Circles, Lines.
type RenderOptions struct {
Layers []RenderLayer
Style *RenderStyle
// Incremental controls incremental pan behavior. If nil, defaults are used.
Incremental *IncrementalPolicy
}
var (
errInvalidViewportSize = errors.New("render: invalid viewport size")
errInvalidMargins = errors.New("render: invalid margins")
errNilDrawer = errors.New("render: nil drawer")
)
// RenderParams describes one render request coming from the UI layer.
//
// Camera coordinates are expressed in world fixed-point units and point to the
// center of the visible viewport. Margins are expressed in canvas pixels and
// extend the rendered area around the viewport on each axis independently.
//
// The final canvas size is derived from viewport size and margins:
//
// canvasWidthPx = viewportWidthPx + 2*marginXPx
// canvasHeightPx = viewportHeightPx + 2*marginYPx
type RenderParams struct {
ViewportWidthPx int
ViewportHeightPx int
MarginXPx int
MarginYPx int
CameraXWorldFp int
CameraYWorldFp int
CameraZoom float64
// Optional render options. If nil, defaults are used.
Options *RenderOptions
// Used for various debugging purposes
Debug bool
}
// CanvasWidthPx returns the full expanded canvas width in pixels.
func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx }
// CanvasHeightPx returns the full expanded canvas height in pixels.
func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx }
// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
func (p RenderParams) CameraZoomFp() (int, error) {
return cameraZoomToWorldFixed(p.CameraZoom)
}
// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
// the full expanded canvas around the camera center.
//
// The returned rectangle is expressed in fixed-point world coordinates and is not
// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis;
// torus normalization and tiling are handled later by the renderer pipeline.
func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) {
zoomFp, err := p.CameraZoomFp()
if err != nil {
return Rect{}, err
}
return expandedCanvasWorldRect(
p.CameraXWorldFp,
p.CameraYWorldFp,
p.CanvasWidthPx(),
p.CanvasHeightPx(),
zoomFp,
), nil
}
// Validate checks whether the render request is internally consistent.
// Camera coordinates are intentionally not restricted here because the renderer
// is expected to normalize them through torus wrap.
func (p RenderParams) Validate() error {
if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 {
return errInvalidViewportSize
}
if p.MarginXPx < 0 || p.MarginYPx < 0 {
return errInvalidMargins
}
if _, err := p.CameraZoomFp(); err != nil {
return err
}
if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 {
return errInvalidViewportSize
}
return nil
}
// expandedCanvasWorldRect computes the world-space half-open rectangle covered by
// a full expanded canvas centered on the camera.
//
// The rectangle is returned in fixed-point world coordinates and is not wrapped.
// A later renderer step is expected to tile and normalize it against torus bounds.
func expandedCanvasWorldRect(
cameraXWorldFp, cameraYWorldFp int,
canvasWidthPx, canvasHeightPx int,
zoomFp int,
) Rect {
if canvasWidthPx <= 0 || canvasHeightPx <= 0 {
panic("expandedCanvasWorldRect: invalid canvas size")
}
if zoomFp <= 0 {
panic("expandedCanvasWorldRect: invalid zoom")
}
worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp)
worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp)
minX := cameraXWorldFp - worldWidthFp/2
minY := cameraYWorldFp - worldHeightFp/2
return Rect{
minX: minX,
maxX: minX + worldWidthFp,
minY: minY,
maxY: minY + worldHeightFp,
}
}
// Render draws the current world state onto the expanded canvas represented by drawer.
//
// Stage A implementation is expected to perform a full redraw of the entire
// expanded canvas. Incremental scrolling and canvas shifting are intentionally
// left for later stages.
//
// The renderer must treat the camera as looking at the center of the viewport,
// not the center of the full expanded canvas.
//
// The renderer performs three passes (layers) in a configurable order.
// The render plan (tiling + candidates + clips) is built once and reused.
func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
if drawer == nil {
return errNilDrawer
}
if err := params.Validate(); err != nil {
return err
}
defer func() {
if !params.Debug {
return
}
drawer.AddLine(
float64(params.MarginXPx),
float64(params.MarginYPx),
float64(params.MarginXPx+params.ViewportWidthPx),
float64(params.MarginYPx))
drawer.AddLine(
float64(params.MarginXPx),
float64(params.MarginYPx),
float64(params.MarginXPx),
float64(params.MarginYPx+params.ViewportHeightPx))
drawer.AddLine(
float64(params.MarginXPx+params.ViewportWidthPx),
float64(params.MarginYPx),
float64(params.MarginXPx+params.ViewportWidthPx),
float64(params.MarginYPx+params.ViewportHeightPx))
drawer.AddLine(
float64(params.MarginXPx),
float64(params.MarginYPx+params.ViewportHeightPx),
float64(params.MarginXPx+params.ViewportWidthPx),
float64(params.MarginYPx+params.ViewportHeightPx))
}()
startTs := time.Now()
defer func() {
// record dtRender for future overload heuristics
w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds()
}()
policy := DefaultIncrementalPolicy()
if params.Options != nil && params.Options.Incremental != nil {
policy = *params.Options.Incremental
}
// --- Prepare style / layers (same as before) ---
style := DefaultRenderStyle()
if params.Options != nil && params.Options.Style != nil {
style = *params.Options.Style
}
layers := []RenderLayer{RenderLayerPoints, RenderLayerCircles, RenderLayerLines}
if params.Options != nil && len(params.Options.Layers) > 0 {
layers = params.Options.Layers
}
// --- Try incremental path first when state is initialized and geometry matches ---
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
if derr == nil {
inc, perr := PlanIncrementalPan(
params.CanvasWidthPx(),
params.CanvasHeightPx(),
params.MarginXPx,
params.MarginYPx,
dxPx,
dyPx,
)
if perr != nil {
return perr
}
switch inc.Mode {
case IncrementalNoOp:
// If we accumulated dirty regions during shift-only frames, redraw them now (bounded).
if len(w.renderState.pendingDirty) > 0 {
toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
if len(toDraw) > 0 {
for _, r := range toDraw {
drawer.ClearRect(r.X, r.Y, r.W, r.H)
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H)
default:
panic("render: unknown layer")
}
}
}
w.renderState.pendingDirty = remaining
}
return nil
case IncrementalShift:
// Move existing pending dirty rects together with the backing image shift.
if len(w.renderState.pendingDirty) > 0 {
moved := make([]RectPx, 0, len(w.renderState.pendingDirty))
for _, r := range w.renderState.pendingDirty {
if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok {
moved = append(moved, rr)
}
}
w.renderState.pendingDirty = moved
}
// C5: shift backing pixels, then redraw only dirty strips.
drawer.CopyShift(inc.DxPx, inc.DyPx)
overBudget := false
if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 {
budgetNs := int64(policy.RenderBudgetMs) * 1_000_000
if w.renderState.lastRenderDurationNs > budgetNs {
overBudget = true
}
}
if overBudget {
// Shift-only: defer drawing; remember newly exposed strips.
if len(inc.Dirty) > 0 {
w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...)
}
return nil
}
// [ ] Сразу после overBudget вычисления и до построения dirtyPlan
// Draw both the newly exposed strips and any previously deferred dirty regions.
// Always redraw newly exposed strips fully.
// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
dirtyToDraw := inc.Dirty
for _, r := range dirtyToDraw {
drawer.ClearRect(r.X, r.Y, r.W, r.H)
}
// Additionally redraw a bounded portion of deferred dirty regions.
if len(w.renderState.pendingDirty) > 0 {
catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
dirtyToDraw = append(dirtyToDraw, catchUp...)
w.renderState.pendingDirty = remaining
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H)
default:
panic("render: unknown layer")
}
}
// State already updated by ComputePanShiftPx (lastWorldRect advanced).
return nil
case IncrementalFullRedraw:
// Fall through to full redraw below.
default:
panic("render: unknown incremental mode")
}
}
// --- Full redraw path ---
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
drawer.ClearAll()
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, plan, w.W, w.H)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, plan, w.W, w.H)
default:
panic("render: unknown layer")
}
}
return w.CommitFullRedrawState(params)
}
// ForceFullRedrawNext resets internal incremental renderer state.
// After this call, the next Render() will use the full redraw path and
// re-initialize incremental state.
func (w *World) ForceFullRedrawNext() {
w.renderState.Reset()
}
// WorldTile describes one torus tile contribution for an unwrapped world rect.
//
// Rect is the portion of the unwrapped rect mapped into the canonical world domain
// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle.
// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height)
// that map this canonical rect back into the unwrapped coordinate space.
type WorldTile struct {
Rect Rect
OffsetX int
OffsetY int
}
// tileWorldRect splits an unwrapped world-space rect into a set of tiles,
// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp).
//
// Unlike splitByWrap, this function does NOT collapse spans wider than the world.
// If rect spans multiple world widths/heights, it returns multiple tiles.
// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index.
func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
if worldWidthFp <= 0 || worldHeightFp <= 0 {
panic("tileWorldRect: non-positive world size")
}
width := rect.maxX - rect.minX
height := rect.maxY - rect.minY
if width <= 0 || height <= 0 {
return nil
}
// Determine which torus tiles the rect intersects.
// Since rect is half-open, use (max-1) for inclusive end.
minTileX := floorDiv(rect.minX, worldWidthFp)
maxTileX := floorDiv(rect.maxX-1, worldWidthFp)
minTileY := floorDiv(rect.minY, worldHeightFp)
maxTileY := floorDiv(rect.maxY-1, worldHeightFp)
out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1))
for tx := minTileX; tx <= maxTileX; tx++ {
tileBaseX := tx * worldWidthFp
segMinX := max(rect.minX, tileBaseX)
segMaxX := min(rect.maxX, tileBaseX+worldWidthFp)
if segMinX >= segMaxX {
continue
}
localMinX := segMinX - tileBaseX
localMaxX := segMaxX - tileBaseX
for ty := minTileY; ty <= maxTileY; ty++ {
tileBaseY := ty * worldHeightFp
segMinY := max(rect.minY, tileBaseY)
segMaxY := min(rect.maxY, tileBaseY+worldHeightFp)
if segMinY >= segMaxY {
continue
}
localMinY := segMinY - tileBaseY
localMaxY := segMaxY - tileBaseY
out = append(out, WorldTile{
Rect: Rect{
minX: localMinX, maxX: localMaxX,
minY: localMinY, maxY: localMaxY,
},
OffsetX: tileBaseX,
OffsetY: tileBaseY,
})
}
}
return out
}
func isEmptyRectPx(r RectPx) bool {
return r.W <= 0 || r.H <= 0
}
func intersectRectPx(a, b RectPx) (RectPx, bool) {
ax2 := a.X + a.W
ay2 := a.Y + a.H
bx2 := b.X + b.W
by2 := b.Y + b.H
x1 := max(a.X, b.X)
y1 := max(a.Y, b.Y)
x2 := min(ax2, bx2)
y2 := min(ay2, by2)
w := x2 - x1
h := y2 - y1
if w <= 0 || h <= 0 {
return RectPx{}, false
}
return RectPx{X: x1, Y: y1, W: w, H: h}, true
}
+140
View File
@@ -0,0 +1,140 @@
package world
// renderCirclesStageA performs a full expanded-canvas redraw but renders ONLY Circle primitives.
func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
drawCirclesFromPlan(drawer, plan, w.W, w.H)
return nil
}
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Filter only circles; skip tiles that have no circles.
circles := make([]Circle, 0, len(td.Candidates))
for _, it := range td.Candidates {
c, ok := it.(Circle)
if !ok {
continue
}
circles = append(circles, c)
}
if len(circles) == 0 {
continue
}
// Determine which circle copies actually intersect this tile segment.
type circleCopy struct {
c Circle
dx int
dy int
}
copiesToDraw := make([]circleCopy, 0, len(circles))
for _, c := range circles {
shifts := circleWrapShifts(c, worldW, worldH)
for _, s := range shifts {
if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) {
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, cc := range copiesToDraw {
c := cc.c
// Project the circle center for this tile copy (tile offset + wrap shift).
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+cc.dx)-plan.WorldRect.minX, plan.ZoomFp)
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+cc.dy)-plan.WorldRect.minY, plan.ZoomFp)
// Radius is a world span.
rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp)
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
}
drawer.Fill()
drawer.Restore()
}
}
type wrapShift struct {
dx int
dy int
}
// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render
// all torus copies of the circle inside the canonical world domain.
// The (0,0) shift is always present.
func circleWrapShifts(c Circle, worldW, worldH int) []wrapShift {
// If radius covers the whole axis, additional copies are not useful.
// (One copy already covers everything under any reasonable clip.)
if c.Radius >= worldW || c.Radius >= worldH {
return []wrapShift{{dx: 0, dy: 0}}
}
xShifts := []int{0}
yShifts := []int{0}
if c.X+c.Radius >= worldW {
xShifts = append(xShifts, -worldW)
}
if c.X-c.Radius < 0 {
xShifts = append(xShifts, worldW)
}
if c.Y+c.Radius >= worldH {
yShifts = append(yShifts, -worldH)
}
if c.Y-c.Radius < 0 {
yShifts = append(yShifts, worldH)
}
out := make([]wrapShift, 0, len(xShifts)*len(yShifts))
for _, dx := range xShifts {
for _, dy := range yShifts {
out = append(out, wrapShift{dx: dx, dy: dy})
}
}
return out
}
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis.
func circleCopyIntersectsTile(c Circle, dx, dy int, tile WorldTile, worldW, worldH int) bool {
// Unwrapped tile segment bounds.
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
// Circle bbox in the same unwrapped space (apply shift + tile offset).
cx := c.X + tile.OffsetX + dx
cy := c.Y + tile.OffsetY + dy
minX := cx - c.Radius
maxX := cx + c.Radius
minY := cy - c.Radius
maxY := cy + c.Radius
// Treat bbox as half-open for intersection checks.
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}
+163
View File
@@ -0,0 +1,163 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
t.Parallel()
// World is 10x10 world units => 10000x10000 fixed.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Circle near origin so that in expanded canvas (bigger than world)
// it will appear in multiple torus tiles.
id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000
require.NoError(t, err)
w.indexObject(w.objects[id])
// Same geometry as points-only test:
// viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
// Expect 4 circle copies, one per tile that covers the expanded canvas.
wantNames := []string{
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
}
require.Equal(t, wantNames, d.CommandNames())
// At zoom=1, 1 world unit -> 1 px, so:
// circle center at (1,1) => base copy at (3,3) like point test
// radius 1 => 1 px
//
// The rest are shifted by +10px in X and/or Y due to torus tiling.
{
clip := requireDrawerCommandAt(t, d, 1)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 2, 2, 10, 10)
c := requireDrawerCommandAt(t, d, 2)
require.Equal(t, "AddCircle", c.Name)
requireCommandArgs(t, c, 3, 3, 1)
}
{
clip := requireDrawerCommandAt(t, d, 6)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 2, 12, 10, 2)
c := requireDrawerCommandAt(t, d, 7)
require.Equal(t, "AddCircle", c.Name)
requireCommandArgs(t, c, 3, 13, 1)
}
{
clip := requireDrawerCommandAt(t, d, 11)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 12, 2, 2, 10)
c := requireDrawerCommandAt(t, d, 12)
require.Equal(t, "AddCircle", c.Name)
requireCommandArgs(t, c, 13, 3, 1)
}
{
clip := requireDrawerCommandAt(t, d, 16)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 12, 12, 2, 2)
c := requireDrawerCommandAt(t, d, 17)
require.Equal(t, "AddCircle", c.Name)
requireCommandArgs(t, c, 13, 13, 1)
}
}
func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Add only a point, no circles.
id, err := w.AddPoint(5, 5)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
// No circles => no commands.
require.Empty(t, d.Commands())
}
func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
w.resetGrid(10 * SCALE)
// radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1.
id, err := w.AddCircle(50, 50, 2)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 50 * SCALE,
CameraYWorldFp: 50 * SCALE,
CameraZoom: 2.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
// There should be at least one AddCircle.
cmds := d.CommandsByName("AddCircle")
require.NotEmpty(t, cmds)
// All circles in this plan should have radius 4px (2 units * 2x zoom).
for _, c := range cmds {
require.Len(t, c.Args, 3)
require.Equal(t, 4.0, c.Args[2])
}
}
@@ -0,0 +1,93 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
t.Parallel()
// World 10x10 units => 10px at zoom=1 when viewport==world.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
type tc struct {
name string
x, y float64
r float64
wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0
}
// Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
tests := []tc{
{
name: "bottom boundary wraps to top",
x: 5, y: 9, r: 2,
// Centers: original at y=9, copy at y=-1.
wantCenters: [][2]float64{{5, 9}, {5, -1}},
},
{
name: "right boundary wraps to left",
x: 9, y: 5, r: 2,
wantCenters: [][2]float64{{9, 5}, {-1, 5}},
},
{
name: "corner wraps to three extra copies",
x: 9, y: 9, r: 2,
wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}},
},
{
name: "no wrap inside",
x: 5, y: 5, r: 2,
wantCenters: [][2]float64{{5, 5}},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w2 := NewWorld(10, 10)
w2.resetGrid(2 * SCALE)
_, err := w2.AddCircle(tt.x, tt.y, tt.r)
require.NoError(t, err)
for _, obj := range w2.objects {
w2.indexObject(obj)
}
plan, err := w2.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w2.W, w2.H)
cmds := d.CommandsByName("AddCircle")
require.Len(t, cmds, len(tt.wantCenters))
// Collect centers (ignore radius for this test).
got := make([][2]float64, 0, len(cmds))
for _, c := range cmds {
require.Len(t, c.Args, 3)
got = append(got, [2]float64{c.Args[0], c.Args[1]})
}
// Order is deterministic with our shift generation and tile iteration for margin=0: single tile.
require.ElementsMatch(t, tt.wantCenters, got)
})
}
}
@@ -0,0 +1,190 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1, // 1ms budget
},
},
}
// First render (full) initializes state.
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
require.True(t, w.renderState.initialized)
// Pretend previous render was very slow => over budget for the next frame.
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
// Pan right by 1 unit => incremental shift candidate.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params2))
// Shift-only should call CopyShift but not redraw dirty rects.
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
require.Empty(t, d1.CommandsByName("ClipRect"))
require.NotEmpty(t, w.renderState.pendingDirty)
// Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty.
w.renderState.lastRenderDurationNs = 0 // under budget
params3 := params2 // same camera
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params3))
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
require.Empty(t, w.renderState.pendingDirty)
}
func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
policy := &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1,
}
base := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: policy,
},
}
// Initial full render.
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
// Frame 1: over budget => shift-only, pendingDirty accumulates.
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
p1 := base
p1.CameraXWorldFp += 1 * SCALE
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, p1))
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
require.Empty(t, d1.CommandsByName("ClipRect"))
require.NotEmpty(t, w.renderState.pendingDirty)
// Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty).
w.renderState.lastRenderDurationNs = 0
p2 := p1
p2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, p2))
require.NotEmpty(t, d2.CommandsByName("CopyShift"))
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw")
}
func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
policy := &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1,
MaxCatchUpAreaPx: 20, // very small budget
}
base := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 2,
MarginXPx: 4,
MarginYPx: 4, // canvasH = 2 + 8 = 10
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: policy,
},
}
// Full init
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
// Over budget => shift-only twice to accumulate pending dirty.
w.renderState.lastRenderDurationNs = 10_000_000
p1 := base
p1.CameraXWorldFp += 1 * SCALE
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1))
require.NotEmpty(t, w.renderState.pendingDirty)
w.renderState.lastRenderDurationNs = 10_000_000
p2 := p1
p2.CameraXWorldFp += 1 * SCALE
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
require.NotEmpty(t, w.renderState.pendingDirty)
// Under budget now, but limit catch-up.
w.renderState.lastRenderDurationNs = 0
before := len(w.renderState.pendingDirty)
require.Greater(t, before, 0)
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
after := len(w.renderState.pendingDirty)
// With a tiny MaxCatchUpAreaPx we should not clear everything in one go.
require.Greater(t, after, 0)
require.Less(t, after, before)
}
@@ -0,0 +1,38 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) {
t.Parallel()
pending := []RectPx{
{X: 0, Y: 0, W: 10, H: 1}, // area 10
{X: 0, Y: 1, W: 10, H: 2}, // area 20
{X: 0, Y: 3, W: 10, H: 3}, // area 30
}
// Limit 25 => should take first (10) + second (20) would exceed => take only first.
sel, rem := takeCatchUpRects(pending, 25)
require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel)
require.Equal(t, []RectPx{
{X: 0, Y: 1, W: 10, H: 2},
{X: 0, Y: 3, W: 10, H: 3},
}, rem)
// Limit 30 => can take first(10) + second(20) exactly.
sel, rem = takeCatchUpRects(pending, 30)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 10, H: 1},
{X: 0, Y: 1, W: 10, H: 2},
}, sel)
require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem)
// No limit => take all.
sel, rem = takeCatchUpRects(pending, 0)
require.Len(t, sel, 3)
require.Empty(t, rem)
}
+265
View File
@@ -0,0 +1,265 @@
package world
import "errors"
var (
errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
)
// IncrementalMode describes how the renderer should update the backing image.
type IncrementalMode int
const (
// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
IncrementalNoOp IncrementalMode = iota
// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
IncrementalShift
// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
IncrementalFullRedraw
)
// RectPx is an integer rectangle in canvas pixel coordinates.
// Semantics are half-open: [X, X+W) x [Y, Y+H).
type RectPx struct {
X, Y int
W, H int
}
// IncrementalPolicy is a placeholder for future incremental tuning.
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
type IncrementalPolicy struct {
// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
// This will be implemented later; kept here as a placeholder to lock the API shape.
CoalesceUpdates bool
// AllowShiftOnly allows a temporary mode where the backing image is shifted
// but dirty rects are not redrawn immediately under overload.
AllowShiftOnly bool
// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
RenderBudgetMs int
// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
// 0 means "no limit".
MaxCatchUpAreaPx int
}
// IncrementalPlan is the output of pure incremental planning.
// It does not perform any drawing. It only describes what should happen.
type IncrementalPlan struct {
Mode IncrementalMode
// Shift to apply to the backing image in canvas pixels.
// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
// Positive dy shifts the existing image down (exposing a dirty strip on the top).
DxPx int
DyPx int
// Dirty rects to redraw after shifting (in canvas pixel coordinates).
// Rects may overlap; overlapping is allowed and simplifies planning.
Dirty []RectPx
}
// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
// and redrawing only exposed strips, or must fall back to a full redraw.
//
// Threshold rule (per-axis):
// - If abs(dxPx) > marginXPx/2 => full redraw
// - If abs(dyPx) > marginYPx/2 => full redraw
//
// Additional safety rules:
// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
//
// Returned dirty rects follow the chosen shift direction:
//
// dxPx > 0 => dirty strip on the left (width=dxPx)
// dxPx < 0 => dirty strip on the right (width=-dxPx)
// dyPx > 0 => dirty strip on the top (height=dyPx)
// dyPx < 0 => dirty strip on the bottom(height=-dyPx)
func PlanIncrementalPan(
canvasW, canvasH int,
marginXPx, marginYPx int,
dxPx, dyPx int,
) (IncrementalPlan, error) {
if canvasW <= 0 || canvasH <= 0 {
return IncrementalPlan{}, errInvalidCanvasSize
}
if marginXPx < 0 || marginYPx < 0 {
return IncrementalPlan{}, errors.New("incremental: invalid margins")
}
// No movement => no work.
if dxPx == 0 && dyPx == 0 {
return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
}
adx := abs(dxPx)
ady := abs(dyPx)
// Too large shift cant be represented as "shift + stripes".
if adx >= canvasW || ady >= canvasH {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// Thresholds: per axis, independently.
// Using integer division: margin/2 truncates down, which is fine and deterministic.
thrX := marginXPx / 2
thrY := marginYPx / 2
if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// If margin is 0, thr is 0, and any non-zero delta should force full redraw
// (because we have no buffer area to shift into).
if marginXPx == 0 && dxPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
if marginYPx == 0 && dyPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
dirty := make([]RectPx, 0, 2)
// Horizontal exposed strip with 1px overdraw to avoid seams.
if dxPx > 0 {
// Image moved right => left strip is exposed.
w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
} else if dxPx < 0 {
// Image moved left => right strip is exposed.
w := min((-dxPx)+1, canvasW)
dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
}
// Vertical exposed strip with 1px overdraw to avoid seams.
if dyPx > 0 {
// Image moved down => top strip is exposed.
h := min(dyPx+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
} else if dyPx < 0 {
// Image moved up => bottom strip is exposed.
h := min((-dyPx)+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
}
// Filter out any zero/negative rects defensively.
out := dirty[:0]
for _, r := range dirty {
if r.W <= 0 || r.H <= 0 {
continue
}
out = append(out, r)
}
return IncrementalPlan{
Mode: IncrementalShift,
DxPx: dxPx,
DyPx: dyPx,
Dirty: out,
}, nil
}
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
return inter, ok
}
// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
// TileDrawPlan entry with the clip replaced by the intersection.
//
// This makes drawing functions naturally render only the dirty areas.
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
if len(dirty) == 0 {
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: nil,
}
}
outTiles := make([]TileDrawPlan, 0)
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}
for _, dr := range dirty {
if isEmptyRectPx(dr) {
continue
}
inter, ok := intersectRectPx(tileClip, dr)
if !ok {
continue
}
outTiles = append(outTiles, TileDrawPlan{
Tile: td.Tile,
ClipX: inter.X,
ClipY: inter.Y,
ClipW: inter.W,
ClipH: inter.H,
Candidates: td.Candidates,
})
}
}
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: outTiles,
}
}
// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
if len(pending) == 0 {
return nil, nil
}
if maxAreaPx <= 0 {
// No limit.
all := append([]RectPx(nil), pending...)
return all, nil
}
selected = make([]RectPx, 0, len(pending))
remaining = make([]RectPx, 0)
used := 0
for _, r := range pending {
if r.W <= 0 || r.H <= 0 {
continue
}
area := r.W * r.H
if area <= 0 {
continue
}
// If we cannot fit the whole rect, we stop (simple, deterministic).
// (We do not split rectangles here to keep logic simple.)
if used+area > maxAreaPx {
remaining = append(remaining, r)
continue
}
selected = append(selected, r)
used += area
}
// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
// Note: remaining preserves original order among non-selected entries.
return selected, remaining
}
@@ -0,0 +1,144 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPlanIncrementalPan_NoOp(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0)
require.NoError(t, err)
require.Equal(t, IncrementalNoOp, plan.Mode)
require.Empty(t, plan.Dirty)
}
func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) {
t.Parallel()
_, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0)
require.ErrorIs(t, err, errInvalidCanvasSize)
}
func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) {
t.Parallel()
// marginX=20 => threshold=10, dx=11 => full redraw
plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) {
t.Parallel()
// marginY=20 => threshold=10, dy=-11 => full redraw
plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) {
t.Parallel()
// marginX=40 => threshold=20, dx=5 => shift ok
plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, 5, plan.DxPx)
require.Equal(t, 0, plan.DyPx)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 6, H: 100},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 200 - 8, Y: 0, W: 8, H: 100},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 200, H: 10},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 0, Y: 100 - 10, W: 200, H: 10},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
// Overlap is allowed; we just require both strips exist.
require.Len(t, plan.Dirty, 2)
require.ElementsMatch(t, []RectPx{
{X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip
{X: 0, Y: 0, W: 200, H: 9}, // top strip
}, plan.Dirty)
}
func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
// Right strip width should be abs(dx)+1 = 8.
require.Equal(t, []RectPx{
{X: 200 - 8, Y: 0, W: 8, H: 100},
}, plan.Dirty)
}
@@ -0,0 +1,114 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
_, err = w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4, // threshold=2
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
// First render initializes state (full redraw).
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
// Pan right by 1 unit => dx=-1 => incremental shift expected.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d := &fakePrimitiveDrawer{}
err = w.Render(d, params2)
require.NoError(t, err)
// Must contain CopyShift for incremental path.
require.NotEmpty(t, d.CommandsByName("CopyShift"))
// All clip rects should be "small": width <= 1 for dx=-1 strip.
clipCmds := d.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
for _, c := range clipCmds {
wPx := int(c.Args[2])
hPx := int(c.Args[3])
require.LessOrEqual(t, wPx, 2)
require.LessOrEqual(t, hPx, params2.CanvasHeightPx())
}
require.NotEmpty(t, d.CommandsByName("AddPoint"))
require.NotEmpty(t, d.CommandsByName("AddCircle"))
require.NotEmpty(t, d.CommandsByName("AddLine"))
}
func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
// Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected.
params2 := params
params2.CameraXWorldFp += 3 * SCALE
d := &fakePrimitiveDrawer{}
err = w.Render(d, params2)
require.NoError(t, err)
// Full redraw should NOT call CopyShift.
require.Empty(t, d.CommandsByName("CopyShift"))
// Should contain at least one reasonably wide clip.
clipCmds := d.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
foundWide := false
for _, c := range clipCmds {
if int(c.Args[2]) > 1 {
foundWide = true
break
}
}
require.True(t, foundWide)
}
+202
View File
@@ -0,0 +1,202 @@
package world
import "errors"
var (
errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required")
errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required")
errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom")
errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size")
)
// rendererIncrementalState stores the minimum state needed for incremental pan.
type rendererIncrementalState struct {
initialized bool
// Last render geometry key.
lastZoomFp int
lastViewportW int
lastViewportH int
lastMarginX int
lastMarginY int
lastCanvasW int
lastCanvasH int
// Last unwrapped expanded world rect used for rendering.
lastWorldRect Rect
// Remainders in numerator space to make world->px conversion stable across many small pans.
// We keep them per axis and update them during conversion.
remXNum int64
remYNum int64
// Last measured render duration (nanoseconds). Used for overload heuristics.
lastRenderDurationNs int64
// Pending dirty areas accumulated during shift-only frames.
// These are in current canvas pixel coordinates.
pendingDirty []RectPx
}
// Reset clears incremental state, forcing next frame to use full redraw.
func (s *rendererIncrementalState) Reset() {
*s = rendererIncrementalState{}
}
// incrementalKeyFromParams extracts the geometry key that must match for incremental pan.
func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) {
vw = params.ViewportWidthPx
vh = params.ViewportHeightPx
mx = params.MarginXPx
my = params.MarginYPx
cw = params.CanvasWidthPx()
ch = params.CanvasHeightPx()
z = zoomFp
return
}
// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp,
// carrying a signed remainder in numerator space to avoid cumulative drift.
//
// The conversion is:
//
// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE))
//
// and rem is updated to the exact remainder.
//
// This function works for negative deltas too and uses floor division semantics.
func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int {
if zoomFp <= 0 {
panic("worldDeltaFixedToCanvasPx: invalid zoom")
}
den := int64(SCALE) * int64(SCALE)
num := int64(deltaWorldFp)*int64(zoomFp) + *remNum
q, r := floorDivRem64(num, den)
*remNum = r
return int(q)
}
// floorDivRem64 returns (q,r) such that:
//
// q = floor(a / b), r = a - q*b
//
// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder).
func floorDivRem64(a, b int64) (q int64, r int64) {
if b <= 0 {
panic("floorDivRem64: non-positive divisor")
}
q = a / b
r = a % b
if r != 0 && a < 0 {
q--
r = a - q*b
}
return q, r
}
// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image
// when ONLY camera pan changed (no zoom/viewport/margins changes).
//
// Returned dxPx/dyPx are shifts to apply to the already rendered image:
//
// dxPx > 0 => shift image right
// dxPx < 0 => shift image left
//
// This function updates internal incremental state when possible.
// If it returns an error, the caller should fall back to a full redraw and call
// CommitFullRedrawState afterward.
func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) {
zoomFp, zerr := params.CameraZoomFp()
if zerr != nil {
return 0, 0, zerr
}
if zoomFp <= 0 {
return 0, 0, errIncrementalInvalidZoomFp
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
if canvasW <= 0 || canvasH <= 0 {
return 0, 0, errIncrementalInvalidCanvasPx
}
newRect, rerr := params.ExpandedCanvasWorldRect()
if rerr != nil {
return 0, 0, rerr
}
s := &w.renderState
// First call: no prior state => must full redraw.
if !s.initialized {
return 0, 0, errIncrementalStateNotReady
}
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
if s.lastZoomFp != z ||
s.lastViewportW != vw || s.lastViewportH != vh ||
s.lastMarginX != mx || s.lastMarginY != my ||
s.lastCanvasW != cw || s.lastCanvasH != ch {
return 0, 0, errIncrementalZoomMismatch
}
// Compute how much the unwrapped world rect moved.
dMinX := newRect.minX - s.lastWorldRect.minX
dMinY := newRect.minY - s.lastWorldRect.minY
// Convert world movement to pixel movement of the world content.
// If world rect moved +X (camera moved right), content appears shifted left,
// so the old image must be shifted left: shiftPx = -deltaPx.
deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum)
deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum)
dxPx = -deltaPxX
dyPx = -deltaPxY
// Update stored rect for the next incremental computation.
s.lastWorldRect = newRect
return dxPx, dyPx, nil
}
// CommitFullRedrawState updates incremental state after a full redraw.
// Call this after you finish a full Render() that draws the entire expanded canvas.
func (w *World) CommitFullRedrawState(params RenderParams) error {
zoomFp, err := params.CameraZoomFp()
if err != nil {
return err
}
if zoomFp <= 0 {
return errIncrementalInvalidZoomFp
}
rect, err := params.ExpandedCanvasWorldRect()
if err != nil {
return err
}
s := &w.renderState
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
s.initialized = true
s.lastZoomFp = z
s.lastViewportW = vw
s.lastViewportH = vh
s.lastMarginX = mx
s.lastMarginY = my
s.lastCanvasW = cw
s.lastCanvasH = ch
s.lastWorldRect = rect
// Reset remainders on a full redraw to avoid stale accumulation when geometry changes.
s.remXNum = 0
s.remYNum = 0
s.pendingDirty = nil
return nil
}
@@ -0,0 +1,171 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) {
t.Parallel()
// zoom=1: px = (deltaWorldFp * 1000) / 1e6
// For deltaWorldFp=1, each step contributes 0 px with remainder,
// and after 1000 steps it must become 1 px total.
zoomFp := SCALE
var rem int64
sum := 0
for i := 0; i < 1000; i++ {
sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem)
}
require.Equal(t, 1, sum)
}
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) {
t.Parallel()
zoomFp := SCALE
var rem int64
sum := 0
for i := 0; i < 1000; i++ {
sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem)
}
require.Equal(t, -1, sum)
}
func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
_, _, err := w.ComputePanShiftPx(params)
require.ErrorIs(t, err, errIncrementalStateNotReady)
}
func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
base := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(base))
changed := base
changed.CameraZoom = 2.0
_, _, err := w.ComputePanShiftPx(changed)
require.ErrorIs(t, err, errIncrementalZoomMismatch)
changed2 := base
changed2.ViewportWidthPx = 101
_, _, err = w.ComputePanShiftPx(changed2)
require.ErrorIs(t, err, errIncrementalZoomMismatch)
}
func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Move camera right by 1 world unit => world rect minX increases by 1 unit,
// so content moves left by 1px at zoom=1 => image shift should be -1.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
dx, dy, err := w.ComputePanShiftPx(params2)
require.NoError(t, err)
require.Equal(t, -1, dx)
require.Equal(t, 0, dy)
}
func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Move camera up by 1 world unit => world rect minY decreases by 1 unit,
// so content moves down by 1px => image shift should be +1 in dy.
params2 := params
params2.CameraYWorldFp -= 1 * SCALE
dx, dy, err := w.ComputePanShiftPx(params2)
require.NoError(t, err)
require.Equal(t, 0, dx)
require.Equal(t, 1, dy)
}
func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Pan camera right by 0.001 world units (1 fixed-point) 1000 times.
// At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1.
totalDx := 0
p := params
for i := 0; i < 1000; i++ {
p.CameraXWorldFp += 1
dx, dy, err := w.ComputePanShiftPx(p)
require.NoError(t, err)
require.Equal(t, 0, dy)
totalDx += dx
}
require.Equal(t, -1, totalDx)
}
+228
View File
@@ -0,0 +1,228 @@
package world
// renderLinesStageB performs a full expanded-canvas redraw but renders ONLY Line primitives.
// It uses the Stage A render plan: tiles + per-tile clip + per-tile candidates.
func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
drawLinesFromPlan(drawer, plan, w.W, w.H)
return nil
}
// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn.
// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting.
type lineSeg struct {
x1, y1 int
x2, y2 int
}
// drawLinesFromPlan executes a lines-only draw from an already built render plan.
func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Filter only lines; skip tiles that have no lines.
lines := make([]Line, 0, len(td.Candidates))
for _, it := range td.Candidates {
l, ok := it.(Line)
if !ok {
continue
}
lines = append(lines, l)
}
if len(lines) == 0 {
continue
}
// Collect segments that actually intersect this tile's canonical rect.
segsToDraw := make([]lineSeg, 0, len(lines))
for _, l := range lines {
segs := torusShortestLineSegments(l, worldW, worldH)
for _, s := range segs {
if segmentIntersectsRect(s, td.Tile.Rect) {
segsToDraw = append(segsToDraw, s)
}
}
}
if len(segsToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, s := range segsToDraw {
// Project endpoints for this tile copy by adding tile offset.
x1Px := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y1Px := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
x2Px := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y2Px := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddLine(float64(x1Px), float64(y1Px), float64(x2Px), float64(y2Px))
}
drawer.Stroke()
drawer.Restore()
}
}
// torusShortestLineSegments converts a Line primitive into 1..4 canonical segments
// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline.
//
// IMPORTANT: when a segment crosses a torus boundary, the second segment starts
// on the opposite boundary (e.g. x=0 jump to x=worldW), preserving continuity on the torus.
// We must NOT wrap endpoints independently at the end, otherwise the topology changes.
func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg {
// Step 1: choose the torus-shortest representation in unwrapped space.
ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW)
ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH)
// Step 2: shift so that A is inside canonical [0..W) x [0..H).
shiftX := floorDiv(ax, worldW) * worldW
shiftY := floorDiv(ay, worldH) * worldH
ax -= shiftX
bx -= shiftX
ay -= shiftY
by -= shiftY
segs := []lineSeg{{x1: ax, y1: ay, x2: bx, y2: by}}
// Step 3: split by X boundary if needed (jump-aware).
segs = splitSegmentsByX(segs, worldW)
// Step 4: split by Y boundary if needed (jump-aware).
segs = splitSegmentsByY(segs, worldH)
// Now all segments are canonical and torus-continuous. Endpoints may legally be 0 or worldW/worldH.
return segs
}
func splitSegmentsByX(segs []lineSeg, worldW int) []lineSeg {
out := make([]lineSeg, 0, len(segs)*2)
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, x1 is expected inside [0..worldW). Only x2 may be outside.
if x2 >= 0 && x2 < worldW {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dx == 0 {
// Degenerate; keep as-is (should not happen with normalized x1 unless x2==x1).
out = append(out, s)
continue
}
if x2 >= worldW {
// Crosses the right boundary at x=worldW, then reappears at x=0.
bx := worldW
num := bx - x1
iy := y1 + (dy*num)/dx
// Segment 1: [x1..worldW]
// Segment 2: [0..x2-worldW]
s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy}
s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2}
out = append(out, s1, s2)
continue
}
// x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW.
bx := 0
num := bx - x1
iy := y1 + (dy*num)/dx
// Segment 1: [x1..0]
// Segment 2: [worldW..x2+worldW]
s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy}
s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2}
out = append(out, s1, s2)
}
return out
}
func splitSegmentsByY(segs []lineSeg, worldH int) []lineSeg {
out := make([]lineSeg, 0, len(segs)*2)
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, y1 is expected inside [0..worldH). Only y2 may be outside.
if y2 >= 0 && y2 < worldH {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dy == 0 {
out = append(out, s)
continue
}
if y2 >= worldH {
// Crosses the top boundary at y=worldH, then reappears at y=0.
by := worldH
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH}
s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH}
out = append(out, s1, s2)
continue
}
// y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH.
by := 0
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0}
s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH}
out = append(out, s1, s2)
}
return out
}
// segmentIntersectsRect is a coarse bbox intersection check between a segment and a half-open rect.
// It is used only as an optimization to avoid drawing clearly irrelevant segments.
//
// NOTE: Segment endpoints may legally be exactly on the world boundary (x==worldW or y==worldH).
// Rect is half-open [min, max), so we treat the segment bbox as inclusive and intersect it with
// [r.min, r.max-1] to avoid dropping boundary-touching segments.
func segmentIntersectsRect(s lineSeg, r Rect) bool {
minX := min(s.x1, s.x2)
maxX := max(s.x1, s.x2)
minY := min(s.y1, s.y2)
maxY := max(s.y1, s.y2)
// Treat degenerate as having 1-unit thickness for indexing-like behavior.
if minX == maxX {
maxX++
}
if minY == maxY {
maxY++
}
// Rect inclusive bounds for intersection purposes.
rMaxX := r.maxX - 1
rMaxY := r.maxY - 1
if rMaxX < r.minX || rMaxY < r.minY {
return false
}
// Inclusive overlap check.
return !(maxX-1 < r.minX || minX > rMaxX || maxY-1 < r.minY || minY > rMaxY)
}
+158
View File
@@ -0,0 +1,158 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDrawLinesFromPlan_WrapX_SplitsAndDrawsInThreeXTiles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Horizontal line that wraps across X: 9 -> 1 at y=5.
id, err := w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawLinesFromPlan(d, plan, w.W, w.H)
// Expect drawing in 3 X tiles (left partial, middle full, right partial) for the central Y tile:
// Each tile group: Save, ClipRect, AddLine(s), Stroke, Restore
//
// Left tile (offsetX=-10000): 1 line segment.
// Middle tile (offsetX=0): 2 segments (wrapped split).
// Right tile (offsetX=10000): 1 segment.
wantNames := []string{
"Save", "ClipRect", "AddLine", "Stroke", "Restore",
"Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore",
"Save", "ClipRect", "AddLine", "Stroke", "Restore",
}
require.Equal(t, wantNames, d.CommandNames())
// Group 1: left strip clip (0,2,2,10), line at y=7 from x=1..2
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 0, 2, 2, 10)
requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 1, 7, 2, 7)
}
// Group 2: middle strip clip (2,2,10,10), two segments:
// segment [9000..10000] => x 11..12, y 7
// segment [0..1000] => x 2..3, y 7
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10)
// The order of segments is stable with our splitting: first the one ending at boundary, then the remainder.
requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 11, 7, 12, 7)
requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 2, 7, 3, 7)
}
// Group 3: right strip clip (12,2,2,10), line at y=7 from x=12..13
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 12, 2, 2, 10) // ClipRect
requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 12, 7, 13, 7) // AddLine
}
}
func TestDrawLinesFromPlan_WrapY_SplitsAndDrawsInThreeYTiles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Vertical line that wraps across Y: 9 -> 1 at x=5.
id, err := w.AddLine(5, 9, 5, 1)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawLinesFromPlan(d, plan, w.W, w.H)
// Here we expect 3 Y tiles for the central X tile:
// Top partial, middle full (two segments), bottom partial.
//
// The exact ordering of tiles is by tx then ty, so X=middle strips first.
// For this geometry the line only intersects the middle X tiles (offsetX=0),
// but spans Y across -1,0,1.
wantNames := []string{
"Save", "ClipRect", "AddLine", "Stroke", "Restore",
"Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore",
"Save", "ClipRect", "AddLine", "Stroke", "Restore",
}
require.Equal(t, wantNames, d.CommandNames())
// Group 1: top strip clip (2,0,10,2), line at x=7 from y=1..2
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 2, 0, 10, 2)
requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 7, 1, 7, 2)
}
// Group 2: middle strip clip (2,2,10,10), two segments:
// segment [9000..10000] => y 11..12 at x=7
// segment [0..1000] => y 2..3 at x=7
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10)
requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 7, 11, 7, 12)
requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 7, 2, 7, 3)
}
// Group 3: bottom strip clip (2,12,10,2), line at x=7 from y=12..13
{
requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 2, 12, 10, 2) // ClipRect
requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 7, 12, 7, 13) // AddLine
}
}
func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) {
t.Parallel()
// World 10 units => 10000 fixed.
worldW := 10 * SCALE
worldH := 10 * SCALE
// Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000).
// Deterministic rule chooses negative delta representation (wrap is applied).
l := Line{
X1: 1 * SCALE, Y1: 5 * SCALE,
X2: 6 * SCALE, Y2: 5 * SCALE,
}
segs := torusShortestLineSegments(l, worldW, worldH)
// Expect two horizontal segments:
// [6000..10000] and [0..1000] at y=5000.
require.Len(t, segs, 2)
// Direction is deterministic and follows the chosen negative-delta representation.
require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0])
require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1])
}
+107
View File
@@ -0,0 +1,107 @@
package world
// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams.
// It is a pure description: it does not execute any drawing.
type RenderPlan struct {
CanvasWidthPx int
CanvasHeightPx int
ZoomFp int
// WorldRect is the unwrapped world-space rect covered by the expanded canvas.
WorldRect Rect
// Tiles are ordered in the same order as produced by tileWorldRect:
// increasing tile X index, then increasing tile Y index.
Tiles []TileDrawPlan
}
// TileDrawPlan describes how to draw one torus tile contribution.
type TileDrawPlan struct {
Tile WorldTile
// Clip rect on the expanded canvas in pixel coordinates.
// It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH).
ClipX int
ClipY int
ClipW int
ClipH int
// Candidates are unique per tile (deduped by ID).
Candidates []MapItem
}
// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span
// for the given fixed-point zoom. The conversion is truncating (floor).
func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int {
// spanWorldFp can be negative in some internal cases, but for clip computations
// we always pass non-negative spans.
return (spanWorldFp * zoomFp) / (SCALE * SCALE)
}
// buildRenderPlanStageA builds a full expanded-canvas redraw plan (Stage A).
//
// It assumes the world grid is already built (IndexOnViewportChange called).
// The plan contains per-tile clip rectangles and per-tile candidate lists
// from the spatial index.
func (w *World) buildRenderPlanStageA(params RenderParams) (RenderPlan, error) {
if err := params.Validate(); err != nil {
return RenderPlan{}, err
}
zoomFp, err := params.CameraZoomFp()
if err != nil {
return RenderPlan{}, err
}
worldRect, err := params.ExpandedCanvasWorldRect()
if err != nil {
return RenderPlan{}, err
}
tiles := tileWorldRect(worldRect, w.W, w.H)
// Query candidates per tile.
batches, err := w.collectCandidatesForTiles(tiles)
if err != nil {
return RenderPlan{}, err
}
planTiles := make([]TileDrawPlan, 0, len(batches))
for _, batch := range batches {
tile := batch.Tile
// Convert the tile's canonical rect + offsets into the unwrapped segment.
segMinX := tile.Rect.minX + tile.OffsetX
segMaxX := tile.Rect.maxX + tile.OffsetX
segMinY := tile.Rect.minY + tile.OffsetY
segMaxY := tile.Rect.maxY + tile.OffsetY
// Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY.
clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp)
clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp)
clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp)
clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp)
clipW := clipX2 - clipX
clipH := clipY2 - clipY
planTiles = append(planTiles, TileDrawPlan{
Tile: tile,
ClipX: clipX,
ClipY: clipY,
ClipW: clipW,
ClipH: clipH,
Candidates: batch.Items,
})
}
return RenderPlan{
CanvasWidthPx: params.CanvasWidthPx(),
CanvasHeightPx: params.CanvasHeightPx(),
ZoomFp: zoomFp,
WorldRect: worldRect,
Tiles: planTiles,
}, nil
}
+147
View File
@@ -0,0 +1,147 @@
package world
import "math"
// renderPointsStageA performs a full expanded-canvas redraw but renders ONLY Point primitives.
func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
style := DefaultRenderStyle()
if params.Options != nil && params.Options.Style != nil {
style = *params.Options.Style
}
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
return nil
}
// drawPointsFromPlan keeps backward compatibility for older tests/helpers.
func drawPointsFromPlan(drawer PrimitiveDrawer, plan RenderPlan) {
// Default world sizes are unknown here, so this wrapper is no longer suitable for wrap-aware points.
// Keep it for historical call sites only if they pass through Render().
// Prefer calling drawPointsFromPlanWithRadius with world sizes.
drawPointsFromPlanWithRadius(drawer, plan, 0, 0, DefaultRenderStyle().PointRadiusPx)
}
// drawPointsFromPlanWithRadius executes a points-only draw from an already built render plan,
// using the provided screen-space radius. If worldW/worldH are zero, wrap copies are disabled.
func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, radiusPx float64) {
// Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed.
rPxInt := int(math.Ceil(radiusPx))
if rPxInt < 0 {
rPxInt = 0
}
rWorldFp := 0
if rPxInt > 0 {
rWorldFp = PixelSpanToWorldFixed(rPxInt, plan.ZoomFp)
}
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
points := make([]Point, 0, len(td.Candidates))
for _, it := range td.Candidates {
p, ok := it.(Point)
if !ok {
continue
}
points = append(points, p)
}
if len(points) == 0 {
continue
}
type pointCopy struct {
p Point
dx int
dy int
}
copiesToDraw := make([]pointCopy, 0, len(points))
for _, p := range points {
shifts := pointWrapShifts(p, rWorldFp, worldW, worldH)
for _, s := range shifts {
if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, pc := range copiesToDraw {
p := pc.p
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+pc.dx)-plan.WorldRect.minX, plan.ZoomFp)
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+pc.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddPoint(float64(px), float64(py), radiusPx)
}
drawer.Fill()
drawer.Restore()
}
}
func pointWrapShifts(p Point, rWorldFp, worldW, worldH int) []wrapShift {
// If world sizes are unknown, do not generate wrap copies.
if worldW <= 0 || worldH <= 0 {
return []wrapShift{{dx: 0, dy: 0}}
}
xShifts := []int{0}
yShifts := []int{0}
if p.X+rWorldFp >= worldW {
xShifts = append(xShifts, -worldW)
}
if p.X-rWorldFp < 0 {
xShifts = append(xShifts, worldW)
}
if p.Y+rWorldFp >= worldH {
yShifts = append(yShifts, -worldH)
}
if p.Y-rWorldFp < 0 {
yShifts = append(yShifts, worldH)
}
out := make([]wrapShift, 0, len(xShifts)*len(yShifts))
for _, dx := range xShifts {
for _, dy := range yShifts {
out = append(out, wrapShift{dx: dx, dy: dy})
}
}
return out
}
func pointCopyIntersectsTile(p Point, rWorldFp, dx, dy int, tile WorldTile) bool {
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
px := p.X + tile.OffsetX + dx
py := p.Y + tile.OffsetY + dy
minX := px - rWorldFp
maxX := px + rWorldFp
minY := py - rWorldFp
maxY := py + rWorldFp
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}
+163
View File
@@ -0,0 +1,163 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
t.Parallel()
// World is 10x10 world units => 10000x10000 fixed.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Place a point near the origin so that expanded canvas (larger than world)
// will require torus repetition and the point will appear in multiple tiles.
id, err := w.AddPoint(1.0, 1.0) // (1000,1000)
require.NoError(t, err)
// Index only this object.
w.indexObject(w.objects[id])
// Choose viewport such that viewport==world in pixels at zoom=1:
// - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px.
// - world width=10 units => 10 px.
// Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
// We expect 4 point copies:
// (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1)
// due to expanded rect spanning beyond world on both axes.
wantNames := []string{
"Save", "ClipRect", "AddPoint", "Fill", "Restore",
"Save", "ClipRect", "AddPoint", "Fill", "Restore",
"Save", "ClipRect", "AddPoint", "Fill", "Restore",
"Save", "ClipRect", "AddPoint", "Fill", "Restore",
}
require.Equal(t, wantNames, d.CommandNames())
pointRadiusPx := DefaultRenderStyle().PointRadiusPx
// Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3).
{
clip := requireDrawerCommandAt(t, d, 1)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 2, 2, 10, 10)
pt := requireDrawerCommandAt(t, d, 2)
require.Equal(t, "AddPoint", pt.Name)
requireCommandArgs(t, pt, 3, 3, pointRadiusPx)
}
// Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13).
{
clip := requireDrawerCommandAt(t, d, 6)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 2, 12, 10, 2)
pt := requireDrawerCommandAt(t, d, 7)
require.Equal(t, "AddPoint", pt.Name)
requireCommandArgs(t, pt, 3, 13, pointRadiusPx)
}
// Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3).
{
clip := requireDrawerCommandAt(t, d, 11)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 12, 2, 2, 10)
pt := requireDrawerCommandAt(t, d, 12)
require.Equal(t, "AddPoint", pt.Name)
requireCommandArgs(t, pt, 13, 3, pointRadiusPx)
}
// Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13).
{
clip := requireDrawerCommandAt(t, d, 16)
require.Equal(t, "ClipRect", clip.Name)
requireCommandArgs(t, clip, 12, 12, 2, 2)
pt := requireDrawerCommandAt(t, d, 17)
require.Equal(t, "AddPoint", pt.Name)
requireCommandArgs(t, pt, 13, 13, pointRadiusPx)
}
}
func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Add only a line, no points.
id, err := w.AddLine(2, 2, 8, 2)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
// No points => no drawing commands at all.
require.Empty(t, d.Commands())
}
func TestWorldRender_PointsOnlyStageA(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
// Build index. In real UI it happens via IndexOnViewportChange.
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
// At least one point draw should happen.
require.Contains(t, d.CommandNames(), "AddPoint")
}
+99
View File
@@ -0,0 +1,99 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Style: func() *RenderStyle {
s := DefaultRenderStyle()
s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1
return &s
}(),
},
}
type tc struct {
name string
x, y float64
wantCenters [][2]float64
}
tests := []tc{
{
name: "bottom boundary wraps to top",
x: 5,
y: 9,
wantCenters: [][2]float64{{5, 9}, {5, -1}},
},
{
name: "right boundary wraps to left",
x: 9,
y: 5,
wantCenters: [][2]float64{{9, 5}, {-1, 5}},
},
{
name: "corner wraps to three extra copies",
x: 9,
y: 9,
wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}},
},
{
name: "no wrap inside",
x: 5,
y: 5,
wantCenters: [][2]float64{{5, 5}},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(tt.x, tt.y)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
style := DefaultRenderStyle()
style.PointRadiusPx = 2.0
applyPointStyle(d, style)
drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx)
cmds := d.CommandsByName("AddPoint")
require.Len(t, cmds, len(tt.wantCenters))
got := make([][2]float64, 0, len(cmds))
for _, c := range cmds {
require.Len(t, c.Args, 3)
got = append(got, [2]float64{c.Args[0], c.Args[1]})
}
require.ElementsMatch(t, tt.wantCenters, got)
})
}
}
+80
View File
@@ -0,0 +1,80 @@
package world
import (
"errors"
"github.com/google/uuid"
)
var (
errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first")
)
// TileCandidates binds one torus tile to the list of unique grid candidates
// that intersect the tile rectangle.
//
// Items are not guaranteed to be truly visible; the grid is a coarse spatial index.
// Exact visibility tests are performed later in the renderer pipeline.
type TileCandidates struct {
Tile WorldTile
Items []MapItem
}
// collectCandidatesForTiles queries the world grid for each tile rectangle
// and returns per-tile unique candidate lists.
//
// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by
// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed.
func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) {
if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 {
return nil, errGridNotBuilt
}
out := make([]TileCandidates, 0, len(tiles))
for _, tile := range tiles {
items := w.collectCandidatesForTile(tile.Rect)
out = append(out, TileCandidates{
Tile: tile,
Items: items,
})
}
return out, nil
}
// collectCandidatesForTile returns a unique set of grid candidates for a single
// canonical-world tile rectangle [0..W) x [0..H).
//
// The rectangle must be half-open and expressed in fixed-point world coordinates.
func (w *World) collectCandidatesForTile(r Rect) []MapItem {
// Empty rect => no candidates.
if r.maxX <= r.minX || r.maxY <= r.minY {
return nil
}
// Map rect to cell ranges using the same half-open conventions as indexing:
// the last included cell is computed from (max-1).
colStart := w.worldToCellX(r.minX)
colEnd := w.worldToCellX(r.maxX - 1)
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
seen := make(map[uuid.UUID]struct{})
result := make([]MapItem, 0)
for row := rowStart; row <= rowEnd; row++ {
for col := colStart; col <= colEnd; col++ {
cell := w.grid[row][col]
for _, item := range cell {
id := item.ID()
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, item)
}
}
}
return result
}
+44
View File
@@ -0,0 +1,44 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(1, 1)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
_, err = w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
require.Contains(t, names, "AddCircle")
require.Contains(t, names, "AddLine")
}
+70
View File
@@ -0,0 +1,70 @@
// Pseudo-code / toolkit-agnostic example.
// Goal: never render more than one frame concurrently, and always render the latest params.
//
// Это не “асинхронная работа в фоне”, а чистый паттерн управления вызовами Render.
// Он работает в любом UI, где у тебя есть loop и возможность запускать отрисовку
// (например, через requestAnimationFrame-аналог или таски).
//
// Как это сочетается с CoalesceUpdates
// - Если params.Options.Incremental.CoalesceUpdates == true, UI использует этот scheduler.
// - Если false, UI может пытаться рендерить каждое событие (но это обычно хуже).
//
// # Важный момент
//
// Это не делает рендер асинхронным в смысле “рендерить в фоне” — в реальном UI ты должен
// выполнять w.Render строго в UI thread. Я показал go только как “планировщик”
// (в твоём GUI заменишь на invokeOnMainThread/PostTask/RunOnUI).
package world
import "sync"
type RenderScheduler struct {
w *World
drawer PrimitiveDrawer
// Protects fields below.
mu sync.Mutex
inFlight bool
pending bool
latest RenderParams
}
// RequestRender stores the latest params and schedules rendering.
// If a render is already in progress, it coalesces (drops intermediate requests).
func (s *RenderScheduler) RequestRender(params RenderParams) {
s.mu.Lock()
s.latest = params
if s.inFlight {
s.pending = true
s.mu.Unlock()
return
}
s.inFlight = true
s.mu.Unlock()
// Schedule on the UI thread/event loop. Replace this with your toolkit method.
go s.runOnUIThread()
}
// runOnUIThread should execute on the UI thread.
// Replace the body with actual UI scheduling primitives.
func (s *RenderScheduler) runOnUIThread() {
for {
s.mu.Lock()
params := s.latest
s.mu.Unlock()
_ = s.w.Render(s.drawer, params) // handle error in real code
s.mu.Lock()
if !s.pending {
s.inFlight = false
s.mu.Unlock()
return
}
// There was a newer request while we were rendering. Loop and render latest.
s.pending = false
s.mu.Unlock()
}
}
@@ -0,0 +1,60 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Mix primitives. Use values that are safely inside the world.
_, err := w.AddPoint(1, 1)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
// A line that wraps across X to ensure line splitting logic is exercised.
_, err = w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
// Build index (in UI: IndexOnViewportChange does this).
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
// Execute all three passes over the same plan.
drawPointsFromPlan(d, plan)
drawCirclesFromPlan(d, plan, w.W, w.H)
drawLinesFromPlan(d, plan, w.W, w.H)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
require.Contains(t, names, "AddCircle")
require.Contains(t, names, "AddLine")
// Ensure finalizers were used (points+circles use Fill, lines use Stroke).
require.Contains(t, names, "Fill")
require.Contains(t, names, "Stroke")
}
+44
View File
@@ -0,0 +1,44 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(1, 1)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
drawCirclesFromPlan(d, plan, w.W, w.H)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
require.Contains(t, names, "AddCircle")
}
+54
View File
@@ -0,0 +1,54 @@
package world
import "image/color"
// RenderStyle describes visual parameters for renderer passes.
// It is intentionally screen-space oriented (pixels), since the renderer
// already projects world coordinates into canvas pixels.
type RenderStyle struct {
// PointRadiusPx is the screen-space radius for Point markers.
PointRadiusPx float64
// PointFill is the fill color for points.
PointFill color.Color
// CircleFill is the fill color for circles.
CircleFill color.Color
// LineStroke is the stroke color for lines.
LineStroke color.Color
// LineWidthPx is the stroke width for lines.
LineWidthPx float64
// LineDash is the dash pattern for lines. Empty => solid.
LineDash []float64
// LineDashOffset is the dash phase for lines.
LineDashOffset float64
}
// DefaultRenderStyle returns the default style used when UI does not provide one.
// Defaults are intentionally simple and stable for testing.
func DefaultRenderStyle() RenderStyle {
return RenderStyle{
PointRadiusPx: 2.0,
PointFill: color.White,
CircleFill: color.White,
LineStroke: color.White,
LineWidthPx: 2.0,
LineDash: nil,
LineDashOffset: 0,
}
}
func DefaultIncrementalPolicy() IncrementalPolicy {
return IncrementalPolicy{
CoalesceUpdates: false,
AllowShiftOnly: false,
RenderBudgetMs: 0,
MaxCatchUpAreaPx: 0,
}
}
+19
View File
@@ -0,0 +1,19 @@
package world
// applyPointStyle configures drawer state for point rendering.
func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetFillColor(style.PointFill)
}
// applyCircleStyle configures drawer state for circle rendering.
func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetFillColor(style.CircleFill)
}
// applyLineStyle configures drawer state for line rendering.
func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetStrokeColor(style.LineStroke)
drawer.SetLineWidth(style.LineWidthPx)
drawer.SetDash(style.LineDash...)
drawer.SetDashOffset(style.LineDashOffset)
}
+58
View File
@@ -0,0 +1,58 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldRender_AppliesLineStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
custom := DefaultRenderStyle()
custom.LineWidthPx = 3
custom.LineDash = []float64{5, 2}
custom.LineDashOffset = 1
custom.LineStroke = color.RGBA{R: 255, A: 255}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Layers: []RenderLayer{RenderLayerLines},
Style: &custom,
},
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
// There must be at least one AddLine call, and every AddLine must observe
// the configured line state snapshot.
cmds := d.CommandsByName("AddLine")
require.NotEmpty(t, cmds)
for _, cmd := range cmds {
require.Equal(t, 3.0, cmd.LineWidth)
require.Equal(t, []float64{5, 2}, cmd.Dashes)
require.Equal(t, 1.0, cmd.DashOffset)
require.Equal(t, color.RGBA{R: 255, A: 255}, cmd.StrokeColor)
}
}
+697
View File
@@ -0,0 +1,697 @@
package world
import (
"sort"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestRenderParamsCanvasSize(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
}
require.Equal(t, 150, params.CanvasWidthPx())
require.Equal(t, 120, params.CanvasHeightPx())
}
func TestRenderParamsCameraZoomFp(t *testing.T) {
t.Parallel()
params := RenderParams{
CameraZoom: 1.25,
}
zoomFp, err := params.CameraZoomFp()
require.NoError(t, err)
require.Equal(t, 1250, zoomFp)
}
func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 50 * SCALE,
CameraYWorldFp: 70 * SCALE,
CameraZoom: 2.0,
}
rect, err := params.ExpandedCanvasWorldRect()
require.NoError(t, err)
require.Equal(t, 12500, rect.minX)
require.Equal(t, 87500, rect.maxX)
require.Equal(t, 40000, rect.minY)
require.Equal(t, 100000, rect.maxY)
}
func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: -10 * SCALE,
CameraYWorldFp: 3 * SCALE,
CameraZoom: 1.0,
}
rect, err := params.ExpandedCanvasWorldRect()
require.NoError(t, err)
require.Equal(t, -85000, rect.minX)
require.Equal(t, 65000, rect.maxX)
require.Equal(t, -57000, rect.minY)
require.Equal(t, 63000, rect.maxY)
}
func TestRenderParamsValidate(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 123456,
CameraYWorldFp: -987654,
CameraZoom: 1.0,
}
require.NoError(t, params.Validate())
}
func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) {
t.Parallel()
tests := []RenderParams{
{
ViewportWidthPx: 0,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
},
{
ViewportWidthPx: 100,
ViewportHeightPx: 0,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
},
{
ViewportWidthPx: -1,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
},
{
ViewportWidthPx: 100,
ViewportHeightPx: -1,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
},
}
for _, params := range tests {
require.ErrorIs(t, params.Validate(), errInvalidViewportSize)
}
}
func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) {
t.Parallel()
tests := []RenderParams{
{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: -1,
MarginYPx: 20,
CameraZoom: 1.0,
},
{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: -1,
CameraZoom: 1.0,
},
}
for _, params := range tests {
require.ErrorIs(t, params.Validate(), errInvalidMargins)
}
}
func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) {
t.Parallel()
tests := []RenderParams{
{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 0,
},
{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: -1,
},
{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 0.0004,
},
}
for _, params := range tests {
require.EqualError(t, params.Validate(), "invalid camera zoom")
}
}
func TestExpandedCanvasWorldRect(t *testing.T) {
t.Parallel()
rect := expandedCanvasWorldRect(
50*SCALE, 70*SCALE,
150, 120,
2*SCALE,
)
require.Equal(t, 12500, rect.minX)
require.Equal(t, 87500, rect.maxX)
require.Equal(t, 40000, rect.minY)
require.Equal(t, 100000, rect.maxY)
}
func TestExpandedCanvasWorldRectPanics(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = expandedCanvasWorldRect(0, 0, 0, 10, SCALE)
})
require.Panics(t, func() {
_ = expandedCanvasWorldRect(0, 0, 10, 0, SCALE)
})
require.Panics(t, func() {
_ = expandedCanvasWorldRect(0, 0, 10, 10, 0)
})
}
func TestWorldRenderRejectsNilDrawer(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
err := w.Render(nil, RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
})
require.ErrorIs(t, err, errNilDrawer)
}
func TestWorldRenderRejectsInvalidParams(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
ViewportWidthPx: 0,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraZoom: 1.0,
})
require.ErrorIs(t, err, errInvalidViewportSize)
}
func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
})
require.ErrorIs(t, err, errGridNotBuilt)
}
func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Render relies on the spatial grid being built.
// In production UI this is done via IndexOnViewportChange.
w.IndexOnViewportChange(100, 80, 1.0)
err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 12345,
CameraYWorldFp: -67890,
CameraZoom: 1.0,
})
require.NoError(t, err)
}
func TestTileWorldRect_NoWrapSingleTile(t *testing.T) {
t.Parallel()
worldW := 100
worldH := 80
rect := Rect{minX: 10, maxX: 30, minY: 5, maxY: 25}
tiles := tileWorldRect(rect, worldW, worldH)
require.Len(t, tiles, 1)
require.Equal(t, 0, tiles[0].OffsetX)
require.Equal(t, 0, tiles[0].OffsetY)
require.Equal(t, 10, tiles[0].Rect.minX)
require.Equal(t, 30, tiles[0].Rect.maxX)
require.Equal(t, 5, tiles[0].Rect.minY)
require.Equal(t, 25, tiles[0].Rect.maxY)
}
func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) {
t.Parallel()
worldW := 100
worldH := 80
// Crosses the X boundary once: [30..130) maps to:
// tile 0: [30..100) offset 0
// tile 1: [0..30) offset 100
rect := Rect{minX: 30, maxX: 130, minY: 10, maxY: 20}
tiles := tileWorldRect(rect, worldW, worldH)
require.Len(t, tiles, 2)
require.Equal(t, 0, tiles[0].OffsetX)
require.Equal(t, 0, tiles[0].OffsetY)
require.Equal(t, Rect{minX: 30, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect)
require.Equal(t, 100, tiles[1].OffsetX)
require.Equal(t, 0, tiles[1].OffsetY)
require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect)
}
func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) {
t.Parallel()
worldW := 100
worldH := 80
// Crosses boundary around 0: [-20..20) maps to:
// tile -1: [80..100) offset -100
// tile 0: [0..20) offset 0
rect := Rect{minX: -20, maxX: 20, minY: 10, maxY: 20}
tiles := tileWorldRect(rect, worldW, worldH)
require.Len(t, tiles, 2)
require.Equal(t, -100, tiles[0].OffsetX)
require.Equal(t, 0, tiles[0].OffsetY)
require.Equal(t, Rect{minX: 80, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect)
require.Equal(t, 0, tiles[1].OffsetX)
require.Equal(t, 0, tiles[1].OffsetY)
require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect)
}
func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) {
t.Parallel()
worldW := 100
worldH := 80
// Crosses both X and Y boundaries once.
// X: [30..130) => tile 0 [30..100), tile 1 [0..30)
// Y: [60..100) => tile 0 [60..80), tile 1 [0..20)
rect := Rect{minX: 30, maxX: 130, minY: 60, maxY: 100}
tiles := tileWorldRect(rect, worldW, worldH)
require.Len(t, tiles, 4)
// Order: tx ascending, then ty ascending.
require.Equal(t, 0, tiles[0].OffsetX)
require.Equal(t, 0, tiles[0].OffsetY)
require.Equal(t, Rect{minX: 30, maxX: 100, minY: 60, maxY: 80}, tiles[0].Rect)
require.Equal(t, 0, tiles[1].OffsetX)
require.Equal(t, 80, tiles[1].OffsetY)
require.Equal(t, Rect{minX: 30, maxX: 100, minY: 0, maxY: 20}, tiles[1].Rect)
require.Equal(t, 100, tiles[2].OffsetX)
require.Equal(t, 0, tiles[2].OffsetY)
require.Equal(t, Rect{minX: 0, maxX: 30, minY: 60, maxY: 80}, tiles[2].Rect)
require.Equal(t, 100, tiles[3].OffsetX)
require.Equal(t, 80, tiles[3].OffsetY)
require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect)
}
func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) {
t.Parallel()
worldW := 100
worldH := 80
require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 0, minY: 0, maxY: 10}, worldW, worldH))
require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 10, minY: 0, maxY: 0}, worldW, worldH))
require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH))
}
func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) {
t.Parallel()
require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 0, 10) })
require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) })
}
func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
tiles := []WorldTile{
{
Rect: Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H},
},
}
_, err := w.collectCandidatesForTiles(tiles)
require.ErrorIs(t, err, errGridNotBuilt)
}
func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE) // 5x5 grid
// Circle in the middle, radius big enough to cover multiple cells.
id, err := w.AddCircle(5, 5, 2.2)
require.NoError(t, err)
// Build index.
w.indexObject(w.objects[id])
// Query whole world tile.
items := w.collectCandidatesForTile(Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H})
// The circle is indexed into multiple cells, but must appear only once in candidates.
require.Len(t, items, 1)
require.Equal(t, id, items[0].ID())
}
func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE) // cells are [0..2), [2..4), ...
id, err := w.AddPoint(1.0, 1.0) // (1000,1000) => cell (0,0)
require.NoError(t, err)
w.indexObject(w.objects[id])
// Query exactly the first cell as half-open rect.
r := Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: 2 * SCALE}
items := w.collectCandidatesForTile(r)
require.Len(t, items, 1)
require.Equal(t, id, items[0].ID())
// Query adjacent cell (should not contain the point).
r2 := Rect{minX: 2 * SCALE, maxX: 4 * SCALE, minY: 0, maxY: 2 * SCALE}
items2 := w.collectCandidatesForTile(r2)
require.Empty(t, items2)
}
func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Circle near the left edge crossing X=0 boundary.
// With radius 1.0 it covers [-0.5..1.5] in world units => wrap indexes both left and right sides.
id, err := w.AddCircle(0.5, 5.0, 1.0)
require.NoError(t, err)
w.indexObject(w.objects[id])
leftStrip := WorldTile{
Rect: Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: w.H},
}
rightStrip := WorldTile{
Rect: Rect{minX: w.W - 2*SCALE, maxX: w.W, minY: 0, maxY: w.H},
}
batches, err := w.collectCandidatesForTiles([]WorldTile{leftStrip, rightStrip})
require.NoError(t, err)
require.Len(t, batches, 2)
// Expect the circle candidate to appear in both batches (different render offsets later).
require.Len(t, batches[0].Items, 1)
require.Equal(t, id, batches[0].Items[0].ID())
require.Len(t, batches[1].Items, 1)
require.Equal(t, id, batches[1].Items[0].ID())
}
func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) {
t.Parallel()
// World: 100x80 real => 100000x80000 fixed.
w := NewWorld(100, 80)
// Build any grid (cell size doesn't matter for this test, but grid must exist).
w.resetGrid(10 * SCALE)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 50 * SCALE,
CameraYWorldFp: 40 * SCALE,
CameraZoom: 2.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
require.Equal(t, 150, plan.CanvasWidthPx)
require.Equal(t, 120, plan.CanvasHeightPx)
require.Equal(t, 2*SCALE, plan.ZoomFp)
require.Len(t, plan.Tiles, 1)
td := plan.Tiles[0]
require.Equal(t, 0, td.ClipX)
require.Equal(t, 0, td.ClipY)
require.Equal(t, 150, td.ClipW)
require.Equal(t, 120, td.ClipH)
require.Equal(t, 0, td.Tile.OffsetX)
require.Equal(t, 0, td.Tile.OffsetY)
}
func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) {
t.Parallel()
// World: 10x10 => 10000x10000 fixed.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Use zoom=1 and the same canvas geometry as earlier: 150x120.
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
require.Equal(t, 150, plan.CanvasWidthPx)
require.Equal(t, 120, plan.CanvasHeightPx)
require.Equal(t, SCALE, plan.ZoomFp)
type interval struct {
start int
end int
}
// Full tile size in pixels for one whole world width/height at the current zoom.
fullTileXPx := worldSpanFixedToCanvasPx(w.W, plan.ZoomFp)
fullTileYPx := worldSpanFixedToCanvasPx(w.H, plan.ZoomFp)
require.Greater(t, fullTileXPx, 0)
require.Greater(t, fullTileYPx, 0)
// ---- X coverage ----
// Take the first Y strip only to avoid duplicates across Y.
intervalsX := make([]interval, 0, len(plan.Tiles))
for _, td := range plan.Tiles {
if td.ClipY == 0 && td.ClipH > 0 && td.ClipW > 0 {
intervalsX = append(intervalsX, interval{
start: td.ClipX,
end: td.ClipX + td.ClipW,
})
// A single tile must never exceed one whole world-tile width in pixels.
require.LessOrEqual(t, td.ClipW, fullTileXPx, "tile width must not exceed one world tile in pixels")
}
}
require.NotEmpty(t, intervalsX)
sort.Slice(intervalsX, func(i, j int) bool {
if intervalsX[i].start != intervalsX[j].start {
return intervalsX[i].start < intervalsX[j].start
}
return intervalsX[i].end < intervalsX[j].end
})
require.Equal(t, 0, intervalsX[0].start)
cursorX := intervalsX[0].end
for i := 1; i < len(intervalsX); i++ {
require.Equal(t, cursorX, intervalsX[i].start, "gap/overlap in X coverage between intervals %d and %d", i-1, i)
cursorX = intervalsX[i].end
}
require.Equal(t, plan.CanvasWidthPx, cursorX)
// ---- Y coverage ----
// Take the first X strip only to avoid duplicates across X.
intervalsY := make([]interval, 0, len(plan.Tiles))
for _, td := range plan.Tiles {
if td.ClipX == 0 && td.ClipW > 0 && td.ClipH > 0 {
intervalsY = append(intervalsY, interval{
start: td.ClipY,
end: td.ClipY + td.ClipH,
})
// A single tile must never exceed one whole world-tile height in pixels.
require.LessOrEqual(t, td.ClipH, fullTileYPx, "tile height must not exceed one world tile in pixels")
}
}
require.NotEmpty(t, intervalsY)
sort.Slice(intervalsY, func(i, j int) bool {
if intervalsY[i].start != intervalsY[j].start {
return intervalsY[i].start < intervalsY[j].start
}
return intervalsY[i].end < intervalsY[j].end
})
require.Equal(t, 0, intervalsY[0].start)
cursorY := intervalsY[0].end
for i := 1; i < len(intervalsY); i++ {
require.Equal(t, cursorY, intervalsY[i].start, "gap/overlap in Y coverage between intervals %d and %d", i-1, i)
cursorY = intervalsY[i].end
}
require.Equal(t, plan.CanvasHeightPx, cursorY)
}
func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Put one circle in the world and index it, it will occupy multiple cells.
id, err := w.AddCircle(5, 5, 2.2)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
require.NotEmpty(t, plan.Tiles)
// Find any tile that has candidates; expect the circle to appear at most once per tile.
for _, td := range plan.Tiles {
if len(td.Candidates) == 0 {
continue
}
seen := map[uuid.UUID]struct{}{}
for _, it := range td.Candidates {
_, ok := seen[it.ID()]
require.False(t, ok, "candidate duplicated within a tile")
seen[it.ID()] = struct{}{}
}
}
}
func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Initialize state via a full render commit.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.resetGrid(2 * SCALE)
require.NoError(t, w.CommitFullRedrawState(params))
require.True(t, w.renderState.initialized)
w.ForceFullRedrawNext()
require.False(t, w.renderState.initialized)
}
+199
View File
@@ -0,0 +1,199 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// rendererTestEnv groups the common mutable inputs used by renderer tests.
// The environment stores independent horizontal and vertical margins because
// the expanded canvas geometry is derived separately on each axis.
type rendererTestEnv struct {
world *World
drawer *fakePrimitiveDrawer
// Viewport origin and size in canvas pixel coordinates.
viewportX int
viewportY int
viewportW int
viewportH int
// Independent margins around the viewport in canvas pixels.
marginXPx int
marginYPx int
// Final expanded canvas size in pixels.
// In the default setup:
// canvasW = viewportW + 2*marginXPx
// canvasH = viewportH + 2*marginYPx
canvasW int
canvasH int
// Camera center in fixed-point world coordinates.
cameraX int
cameraY int
// Camera zoom in fixed-point representation, if needed by renderer internals.
zoomFp int
}
// newRendererTestEnv returns a baseline renderer test environment.
// The default margins are derived independently from viewport width and height.
func newRendererTestEnv() *rendererTestEnv {
viewportW := 100
viewportH := 80
marginXPx := viewportW / 4
marginYPx := viewportH / 4
return &rendererTestEnv{
world: NewWorld(10, 10),
drawer: &fakePrimitiveDrawer{},
viewportX: marginXPx,
viewportY: marginYPx,
viewportW: viewportW,
viewportH: viewportH,
marginXPx: marginXPx,
marginYPx: marginYPx,
canvasW: viewportW + 2*marginXPx,
canvasH: viewportH + 2*marginYPx,
cameraX: 5 * SCALE,
cameraY: 5 * SCALE,
zoomFp: SCALE,
}
}
// setViewport resets viewport-dependent fields and recomputes margins
// using the default test formula:
//
// marginXPx = viewportW / 4
// marginYPx = viewportH / 4
func (env *rendererTestEnv) setViewport(viewportW, viewportH int) {
env.viewportW = viewportW
env.viewportH = viewportH
env.marginXPx = viewportW / 4
env.marginYPx = viewportH / 4
env.viewportX = env.marginXPx
env.viewportY = env.marginYPx
env.canvasW = env.viewportW + 2*env.marginXPx
env.canvasH = env.viewportH + 2*env.marginYPx
}
// setViewportAndMargins overrides viewport and margins explicitly.
// This is useful for edge cases where the expanded canvas geometry
// must be controlled exactly.
func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) {
env.viewportW = viewportW
env.viewportH = viewportH
env.marginXPx = marginXPx
env.marginYPx = marginYPx
env.viewportX = env.marginXPx
env.viewportY = env.marginYPx
env.canvasW = env.viewportW + 2*env.marginXPx
env.canvasH = env.viewportH + 2*env.marginYPx
}
// viewportRect returns the viewport rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) {
return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH)
}
// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) {
return 0, 0, float64(env.canvasW), float64(env.canvasH)
}
// worldMustAddPoint adds a point to the test world and fails the test on error.
func worldMustAddPoint(t *testing.T, w *World, x, y float64) {
t.Helper()
_, err := w.AddPoint(x, y)
require.NoError(t, err)
}
// worldMustAddCircle adds a circle to the test world and fails the test on error.
func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) {
t.Helper()
_, err := w.AddCircle(x, y, r)
require.NoError(t, err)
}
// worldMustAddLine adds a line to the test world and fails the test on error.
func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) {
t.Helper()
_, err := w.AddLine(x1, y1, x2, y2)
require.NoError(t, err)
}
// requireNoDrawerCommands asserts that the renderer produced no drawing commands.
func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) {
t.Helper()
require.Empty(t, d.Commands())
}
// requireStrokeCommandAt returns a command and asserts that it is Stroke.
func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "Stroke")
return cmd
}
// requireFillCommandAt returns a command and asserts that it is Fill.
func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "Fill")
return cmd
}
// requireAddPointCommandAt returns a command and asserts that it is AddPoint.
func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddPoint")
return cmd
}
// requireAddLineCommandAt returns a command and asserts that it is AddLine.
func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddLine")
return cmd
}
// requireAddCircleCommandAt returns a command and asserts that it is AddCircle.
func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddCircle")
return cmd
}
// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect.
func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) {
t.Helper()
requireCommandClipRects(t, cmd, fakeClipRect{
X: x,
Y: y,
W: w,
H: h,
})
}
+219
View File
@@ -0,0 +1,219 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// rendererTestCase is a generic table-driven renderer test scaffold.
// Replace invoke with the real renderer call once the renderer exists.
type rendererTestCase struct {
name string
// setup prepares the world and optional environment overrides.
setup func(t *testing.T, env *rendererTestEnv)
// invoke calls the renderer under test.
invoke func(t *testing.T, env *rendererTestEnv)
// verify checks the produced fake drawer log.
verify func(t *testing.T, env *rendererTestEnv)
}
func runRendererTestCases(t *testing.T, cases []rendererTestCase) {
t.Helper()
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newRendererTestEnv()
if tc.setup != nil {
tc.setup(t, env)
}
require.NotNil(t, tc.invoke, "renderer test case must define invoke")
require.NotNil(t, tc.verify, "renderer test case must define verify")
tc.invoke(t, env)
tc.verify(t, env)
})
}
}
// TestRenderer_Template_PointCases is a scaffold for future point renderer tests.
func TestRenderer_Template_PointCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "point fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddPoint(t, env.world, 5, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point visible only in horizontal margin copy",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddPoint(t, env.world, 0.1, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point visible only in vertical margin copy",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddPoint(t, env.world, 5, 0.1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point duplicated across torus corner with independent margins",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewportAndMargins(120, 60, 30, 10)
worldMustAddPoint(t, env.world, 0.1, 0.1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
// TestRenderer_Template_LineCases is a scaffold for future line renderer tests.
func TestRenderer_Template_LineCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "line fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddLine(t, env.world, 2, 2, 8, 2)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line wrap copy across x edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddLine(t, env.world, 9, 5, 1, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line wrap copy across y edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddLine(t, env.world, 5, 9, 5, 1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line tie case uses deterministic wrapped representation",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddLine(t, env.world, 1, 5, 6, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests.
func TestRenderer_Template_CircleCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "circle fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddCircle(t, env.world, 5, 5, 1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across horizontal edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddCircle(t, env.world, 0.2, 5, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across vertical edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddCircle(t, env.world, 5, 0.2, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across corner with asymmetric margins",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewportAndMargins(120, 60, 30, 10)
worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
+250
View File
@@ -0,0 +1,250 @@
package world
import (
"errors"
"fmt"
"math"
)
var (
errInvalidCameraZoom = errors.New("invalid camera zoom")
)
const (
// SCALE is the fixed-point multiplier used across the package.
// A real value of 1.0 is represented as SCALE.
SCALE = 1000
// MIN_ZOOM and MAX_ZOOM define the supported zoom range in fixed-point form.
// They are reserved for future validation/clamping logic.
MIN_ZOOM = int(SCALE / 4) // 0.25x
MAX_ZOOM = int(SCALE * 32) // 32x
// cellSizeMin and cellSizeMax bound the automatically selected grid cell size.
cellSizeMin = 16 * SCALE
cellSizeMax = 512 * SCALE
)
// Rect is a half-open rectangle in fixed-point world coordinates:
// [minX, maxX) x [minY, maxY).
type Rect struct {
minX, maxX int
minY, maxY int
}
// wrap maps value into the half-open interval [0, size).
// It supports negative input values and is used for torus coordinates.
func wrap(value, size int) int {
r := value % size
if r < 0 {
r += size
}
return r
}
// clamp limits value to the closed interval [minValue, maxValue].
func clamp(value, minValue, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
// ceilDiv returns ceil(a / b) for positive integers.
func ceilDiv(a, b int) int {
return (a + b - 1) / b
}
// floorDiv returns floor(a / b) for b > 0 and supports negative a.
func floorDiv(a, b int) int {
if b <= 0 {
panic("floorDiv: non-positive divisor")
}
q := a / b
r := a % b
if r != 0 && a < 0 {
q--
}
return q
}
// fixedPoint converts a real value into the package fixed-point representation
// using nearest-integer rounding.
func fixedPoint(v float64) int {
return int(math.Round(v * SCALE))
}
// abs returns the absolute value of v.
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
// viewportPxToWorldFixed converts a viewport size in pixels into the visible
// world size in fixed-point coordinates for the given fixed-point zoom.
func viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp int) (int, int) {
return PixelSpanToWorldFixed(viewportWidthPx, cameraZoomFp),
PixelSpanToWorldFixed(viewportHeightPx, cameraZoomFp)
}
// worldToCell maps a world coordinate to a grid cell index.
// The input coordinate is wrapped on the torus before the cell is computed.
// The function panics when the grid configuration is invalid.
func worldToCell(value, worldSize, cells, cellSize int) int {
if cells <= 0 || cellSize <= 0 {
panic(fmt.Sprintf("worldToCell: cells=%d cellSize=%d", cells, cellSize))
}
wrappedValue := wrap(value, worldSize)
cell := wrappedValue / cellSize
if cell >= cells {
cell = cells - 1
}
return cell
}
// splitByWrap splits a half-open rectangle by torus wrap into 1..4 rectangles
// fully contained inside [0, W) x [0, H).
func splitByWrap(W, H, minX, maxX, minY, maxY int) []Rect {
width := maxX - minX
height := maxY - minY
if width <= 0 || height <= 0 {
return nil
}
if width >= W {
minX = 0
maxX = W
}
if height >= H {
minY = 0
maxY = H
}
type xPart struct {
minX, maxX int
}
xParts := make([]xPart, 0, 2)
if minX >= 0 && maxX <= W {
xParts = append(xParts, xPart{minX: minX, maxX: maxX})
} else {
wrappedMinX := wrap(minX, W)
wrappedMaxX := wrap(maxX, W)
if wrappedMinX < wrappedMaxX {
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: wrappedMaxX})
} else {
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: W})
if wrappedMaxX > 0 {
xParts = append(xParts, xPart{minX: 0, maxX: wrappedMaxX})
}
}
}
result := make([]Rect, 0, 4)
for _, xp := range xParts {
if minY >= 0 && maxY <= H {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: minY, maxY: maxY,
})
continue
}
wrappedMinY := wrap(minY, H)
wrappedMaxY := wrap(maxY, H)
if wrappedMinY < wrappedMaxY {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: wrappedMinY, maxY: wrappedMaxY,
})
} else {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: wrappedMinY, maxY: H,
})
if wrappedMaxY > 0 {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: 0, maxY: wrappedMaxY,
})
}
}
}
return result
}
// PixelSpanToWorldFixed converts a span in pixels into a span in fixed-point
// world coordinates for the given fixed-point zoom.
func PixelSpanToWorldFixed(spanPx int, zoomFp int) int {
return (spanPx * SCALE * SCALE) / zoomFp
}
// shortestWrappedDelta returns a canonical torus representation of the pair
// (from, to) along a single axis of length size.
//
// The resulting delta (b - a) is normalized into the half-open interval
// [-size/2, size/2). This makes the tie-case deterministic:
// when the points are exactly half a world apart, wrap is always applied in
// the direction that produces the negative delta.
func shortestWrappedDelta(from, to, size int) (a int, b int) {
a, b = from, to
delta := to - from
half := size / 2
if delta >= half {
a += size
return
}
if delta < -half {
b += size
return
}
return
}
// cameraZoomToWorldFixed converts a UI-facing zoom multiplier into the package
// fixed-point representation used by world-space calculations.
//
// The input zoom is expected to be a finite positive real value where 1.0 means
// the neutral zoom level. The result is rounded to the nearest fixed-point value.
//
// An error is returned when the input is invalid or when rounding would produce
// a non-positive fixed-point zoom.
func cameraZoomToWorldFixed(cameraZoom float64) (int, error) {
if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) {
return 0, errInvalidCameraZoom
}
zoomFp := int(math.Round(cameraZoom * SCALE))
if zoomFp <= 0 {
return 0, errInvalidCameraZoom
}
return zoomFp, nil
}
// mustCameraZoomToWorldFixed is the panic-on-error variant of
// cameraZoomToWorldFixed. It is intended for internal code paths where invalid
// zoom is considered a programmer or integration error and must fail fast.
func mustCameraZoomToWorldFixed(cameraZoom float64) int {
zoomFp, err := cameraZoomToWorldFixed(cameraZoom)
if err != nil {
panic(err)
}
return zoomFp
}
+365
View File
@@ -0,0 +1,365 @@
package world
import (
"math"
"testing"
"github.com/stretchr/testify/require"
)
func TestWrap(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value int
size int
want int
}{
{name: "zero", value: 0, size: 10, want: 0},
{name: "inside range", value: 7, size: 10, want: 7},
{name: "equal to size", value: 10, size: 10, want: 0},
{name: "greater than size", value: 27, size: 10, want: 7},
{name: "negative one", value: -1, size: 10, want: 9},
{name: "negative many", value: -23, size: 10, want: 7},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := wrap(tt.value, tt.size); got != tt.want {
t.Fatalf("wrap(%d, %d) = %d, want %d", tt.value, tt.size, got, tt.want)
}
})
}
}
func TestClamp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value int
minValue int
maxValue int
want int
}{
{name: "below range", value: -5, minValue: 0, maxValue: 10, want: 0},
{name: "inside range", value: 5, minValue: 0, maxValue: 10, want: 5},
{name: "above range", value: 15, minValue: 0, maxValue: 10, want: 10},
{name: "equal min", value: 0, minValue: 0, maxValue: 10, want: 0},
{name: "equal max", value: 10, minValue: 0, maxValue: 10, want: 10},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want {
t.Fatalf("clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want)
}
})
}
}
func TestCeilDiv(t *testing.T) {
t.Parallel()
tests := []struct {
a int
b int
want int
}{
{a: 1, b: 1, want: 1},
{a: 1, b: 2, want: 1},
{a: 2, b: 2, want: 1},
{a: 3, b: 2, want: 2},
{a: 10, b: 3, want: 4},
{a: 16, b: 8, want: 2},
{a: 17, b: 8, want: 3},
}
for _, tt := range tests {
if got := ceilDiv(tt.a, tt.b); got != tt.want {
t.Fatalf("ceilDiv(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestFloorDiv(t *testing.T) {
t.Parallel()
require.Equal(t, 0, floorDiv(0, 10))
require.Equal(t, 0, floorDiv(1, 10))
require.Equal(t, 0, floorDiv(9, 10))
require.Equal(t, 1, floorDiv(10, 10))
require.Equal(t, 1, floorDiv(19, 10))
require.Equal(t, -1, floorDiv(-1, 10))
require.Equal(t, -1, floorDiv(-9, 10))
require.Equal(t, -1, floorDiv(-10, 10))
require.Equal(t, -2, floorDiv(-11, 10))
require.Panics(t, func() { _ = floorDiv(1, 0) })
require.Panics(t, func() { _ = floorDiv(1, -1) })
}
func TestFixedPoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
v float64
want int
}{
{name: "zero", v: 0, want: 0},
{name: "integer", v: 3, want: 3000},
{name: "fraction", v: 1.234, want: 1234},
{name: "round down", v: 1.2344, want: 1234},
{name: "round up", v: 1.2345, want: 1235},
{name: "negative", v: -1.2345, want: -1235},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := fixedPoint(tt.v); got != tt.want {
t.Fatalf("fixedPoint(%f) = %d, want %d", tt.v, got, tt.want)
}
})
}
}
func TestAbs(t *testing.T) {
t.Parallel()
tests := []struct {
v int
want int
}{
{v: 0, want: 0},
{v: 7, want: 7},
{v: -7, want: 7},
}
for _, tt := range tests {
if got := abs(tt.v); got != tt.want {
t.Fatalf("abs(%d) = %d, want %d", tt.v, got, tt.want)
}
}
}
func TestPixelSpanToWorldFixed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spanPx int
zoomFp int
want int
}{
{name: "1x zoom", spanPx: 100, zoomFp: SCALE, want: 100 * SCALE},
{name: "2x zoom", spanPx: 100, zoomFp: 2 * SCALE, want: 50 * SCALE},
{name: "half zoom", spanPx: 100, zoomFp: SCALE / 2, want: 200 * SCALE},
{name: "fractional result truncation", spanPx: 1, zoomFp: 3 * SCALE, want: 333},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := PixelSpanToWorldFixed(tt.spanPx, tt.zoomFp); got != tt.want {
t.Fatalf("PixelSpanToWorldFixed(%d, %d) = %d, want %d", tt.spanPx, tt.zoomFp, got, tt.want)
}
})
}
}
func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cells int
cellSize int
}{
{name: "zero cells", cells: 0, cellSize: 1},
{name: "negative cells", cells: -1, cellSize: 1},
{name: "zero cell size", cells: 1, cellSize: 0},
{name: "negative cell size", cells: 1, cellSize: -1},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
defer func() {
if recover() == nil {
t.Fatalf("worldToCell did not panic for cells=%d cellSize=%d", tt.cells, tt.cellSize)
}
}()
_ = worldToCell(0, 1000, tt.cells, tt.cellSize)
})
}
}
func TestShortestWrappedDelta(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from int
to int
size int
wantA int
wantB int
}{
{name: "no wrap forward", from: 1000, to: 3000, size: 10000, wantA: 1000, wantB: 3000},
{name: "no wrap backward", from: 3000, to: 1000, size: 10000, wantA: 3000, wantB: 1000},
{name: "wrap forward over half", from: 1000, to: 7000, size: 10000, wantA: 11000, wantB: 7000},
{name: "wrap backward over half", from: 7000, to: 1000, size: 10000, wantA: 7000, wantB: 11000},
{name: "tie positive half wraps", from: 1000, to: 6000, size: 10000, wantA: 11000, wantB: 6000},
{name: "tie negative half stays", from: 6000, to: 1000, size: 10000, wantA: 6000, wantB: 1000},
{name: "just below positive half does not wrap", from: 1000, to: 5999, size: 10000, wantA: 1000, wantB: 5999},
{name: "just beyond negative half wraps", from: 6001, to: 1000, size: 10000, wantA: 6001, wantB: 11000},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotA, gotB := shortestWrappedDelta(tt.from, tt.to, tt.size)
if gotA != tt.wantA || gotB != tt.wantB {
t.Fatalf("shortestWrappedDelta(%d, %d, %d) = (%d, %d), want (%d, %d)",
tt.from, tt.to, tt.size, gotA, gotB, tt.wantA, tt.wantB)
}
delta := gotB - gotA
half := tt.size / 2
if delta < -half || delta >= half {
t.Fatalf("normalized delta %d is outside [-%d, %d)", delta, half, half)
}
})
}
}
func TestCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cameraZoom float64
want int
}{
{
name: "neutral zoom",
cameraZoom: 1.0,
want: SCALE,
},
{
name: "integer zoom",
cameraZoom: 2.0,
want: 2 * SCALE,
},
{
name: "fractional zoom",
cameraZoom: 1.25,
want: 1250,
},
{
name: "minimum configured zoom value shape",
cameraZoom: 0.25,
want: SCALE / 4,
},
{
name: "round down",
cameraZoom: 1.2344,
want: 1234,
},
{
name: "round up",
cameraZoom: 1.2345,
want: 1235,
},
{
name: "very small but still positive after rounding",
cameraZoom: 0.0006,
want: 1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cameraZoom float64
}{
{
name: "zero",
cameraZoom: 0,
},
{
name: "negative",
cameraZoom: -1,
},
{
name: "nan",
cameraZoom: math.NaN(),
},
{
name: "positive infinity",
cameraZoom: math.Inf(1),
},
{
name: "negative infinity",
cameraZoom: math.Inf(-1),
},
{
name: "positive but rounds to zero",
cameraZoom: 0.0004,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
require.ErrorIs(t, err, errInvalidCameraZoom)
require.Zero(t, got)
})
}
}
func TestMustCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
require.Equal(t, 1250, mustCameraZoomToWorldFixed(1.25))
require.Panics(t, func() {
_ = mustCameraZoomToWorldFixed(0)
})
}
+208
View File
@@ -0,0 +1,208 @@
package world
import (
"errors"
"fmt"
"github.com/google/uuid"
)
var (
errBadCoordinate = errors.New("invalid coordinates")
errBadRadius = errors.New("invalid radius")
)
// World stores torus world dimensions, all registered objects,
// and the grid-based spatial index built for the current viewport settings.
type World struct {
W, H int // Fixed-point world size.
grid [][][]MapItem
cellSize int
rows, cols int
objects map[uuid.UUID]MapItem
renderState rendererIncrementalState
}
// NewWorld constructs a new world with the given real dimensions.
// The dimensions are converted to fixed-point and must be positive.
func NewWorld(width, height int) *World {
if width <= 0 || height <= 0 {
panic("invalid width or height")
}
return &World{
W: width * SCALE,
H: height * SCALE,
cellSize: 1,
objects: make(map[uuid.UUID]MapItem),
}
}
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
// lies inside the world bounds: [0, W) x [0, H).
func (g *World) checkCoordinate(xf, yf int) bool {
if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H {
return false
}
return true
}
// AddPoint validates and stores a point primitive in the world.
// The input coordinates are given in real world units and are converted
// to fixed-point before validation.
func (g *World) AddPoint(x, y float64) (uuid.UUID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
}
id := uuid.New()
g.objects[id] = Point{Id: id, X: xf, Y: yf}
return id, nil
}
// AddCircle validates and stores a circle primitive in the world.
// The center and radius are given in real world units and are converted
// to fixed-point before validation. A zero radius is allowed.
func (g *World) AddCircle(x, y, r float64) (uuid.UUID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
rf := fixedPoint(r)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
}
if rf < 0 {
return uuid.Nil, errBadRadius
}
id := uuid.New()
g.objects[id] = Circle{Id: id, X: xf, Y: yf, Radius: rf}
return id, nil
}
// AddLine validates and stores a line primitive in the world.
// The endpoints are given in real world units and are converted
// to fixed-point before validation.
func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) {
x1f := fixedPoint(x1)
y1f := fixedPoint(y1)
x2f := fixedPoint(x2)
y2f := fixedPoint(y2)
if ok := g.checkCoordinate(x1f, y1f); !ok {
return uuid.Nil, errBadCoordinate
}
if ok := g.checkCoordinate(x2f, y2f); !ok {
return uuid.Nil, errBadCoordinate
}
id := uuid.New()
g.objects[id] = Line{Id: id, X1: x1f, Y1: y1f, X2: x2f, Y2: y2f}
return id, nil
}
// worldToCellX converts a fixed-point X coordinate to a grid column index.
func (g *World) worldToCellX(x int) int {
return worldToCell(x, g.W, g.cols, g.cellSize)
}
// worldToCellY converts a fixed-point Y coordinate to a grid row index.
func (g *World) worldToCellY(y int) int {
return worldToCell(y, g.H, g.rows, g.cellSize)
}
// resetGrid recreates the spatial grid with the given cell size
// and clears all previous indexing state.
func (g *World) resetGrid(cellSize int) {
g.cellSize = cellSize
g.cols = ceilDiv(g.W, g.cellSize)
g.rows = ceilDiv(g.H, g.cellSize)
g.grid = make([][][]MapItem, g.rows)
for row := range g.grid {
g.grid[row] = make([][]MapItem, g.cols)
}
}
// indexObject inserts a single object into all grid cells touched by its
// indexing representation. Points are inserted into one cell, while circles
// and lines are inserted by their torus-aware bbox coverage.
func (g *World) indexObject(o MapItem) {
switch mapItem := o.(type) {
case Point:
col := g.worldToCellX(mapItem.X)
row := g.worldToCellY(mapItem.Y)
g.grid[row][col] = append(g.grid[row][col], mapItem)
case Line:
x1 := mapItem.X1
y1 := mapItem.Y1
x2 := mapItem.X2
y2 := mapItem.Y2
x1, x2 = shortestWrappedDelta(x1, x2, g.W)
y1, y2 = shortestWrappedDelta(y1, y2, g.H)
minX := min(x1, x2)
maxX := max(x1, x2)
minY := min(y1, y2)
maxY := max(y1, y2)
if minX == maxX {
maxX++
}
if minY == maxY {
maxY++
}
g.indexBBox(mapItem, minX, maxX, minY, maxY)
case Circle:
g.indexBBox(mapItem, mapItem.MinX(), mapItem.MaxX(), mapItem.MinY(), mapItem.MaxY())
default:
panic(fmt.Sprintf("indexing: unknown element %T", mapItem))
}
}
// indexBBox indexes an object by a half-open fixed-point bbox that may cross
// torus boundaries. The bbox is split into wrapped in-world rectangles first,
// then all covered grid cells are populated.
func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY)
for _, r := range rects {
colStart := g.worldToCellX(r.minX)
colEnd := g.worldToCellX(r.maxX - 1)
rowStart := g.worldToCellY(r.minY)
rowEnd := g.worldToCellY(r.maxY - 1)
for col := colStart; col <= colEnd; col++ {
for row := rowStart; row <= rowEnd; row++ {
g.grid[row][col] = append(g.grid[row][col], o)
}
}
}
}
// IndexOnViewportChange rebuilds the grid for a new viewport size and zoom.
// The zoom is provided by the UI as a real multiplier and is converted
// to fixed-point inside the function.
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom)
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp)
cellsAcrossMin := 8
visibleMin := min(worldWidth, worldHeight)
cellSize := visibleMin / cellsAcrossMin
cellSize = clamp(cellSize, cellSizeMin, cellSizeMax)
g.resetGrid(cellSize)
for _, o := range g.objects {
g.indexObject(o)
}
}
+537
View File
@@ -0,0 +1,537 @@
package world
import (
"errors"
"testing"
"github.com/google/uuid"
)
func newIndexedTestWorld() *World {
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE) // 5x5 grid.
return w
}
func cellHasOnlyID(t *testing.T, w *World, row, col int, want uuid.UUID) {
t.Helper()
cell := w.grid[row][col]
if len(cell) != 1 {
t.Fatalf("cell[%d][%d] len = %d, want 1", row, col, len(cell))
}
if got := cell[0].ID(); got != want {
t.Fatalf("cell[%d][%d] item id = %v, want %v", row, col, got, want)
}
}
func cellIsEmpty(t *testing.T, w *World, row, col int) {
t.Helper()
if got := len(w.grid[row][col]); got != 0 {
t.Fatalf("cell[%d][%d] len = %d, want 0", row, col, got)
}
}
func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} {
result := make(map[[2]int]struct{})
for row := range w.grid {
for col := range w.grid[row] {
for _, item := range w.grid[row][col] {
if item.ID() == id {
result[[2]int{row, col}] = struct{}{}
}
}
}
}
return result
}
func assertOccupiedCells(t *testing.T, w *World, id uuid.UUID, want ...[2]int) {
t.Helper()
got := occupiedCellsByID(w, id)
if len(got) != len(want) {
t.Fatalf("occupied cell count = %d, want %d; got=%v want=%v", len(got), len(want), got, want)
}
for _, cell := range want {
if _, ok := got[cell]; !ok {
t.Fatalf("missing occupied cell row=%d col=%d; got=%v", cell[0], cell[1], got)
}
}
}
func TestNewWorld(t *testing.T) {
t.Parallel()
w := NewWorld(12, 7)
if w.W != 12*SCALE {
t.Fatalf("W = %d, want %d", w.W, 12*SCALE)
}
if w.H != 7*SCALE {
t.Fatalf("H = %d, want %d", w.H, 7*SCALE)
}
if w.cellSize != 1 {
t.Fatalf("cellSize = %d, want 1", w.cellSize)
}
if w.objects == nil {
t.Fatal("objects map is nil")
}
}
func TestNewWorldPanicsOnInvalidSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
width int
height int
}{
{name: "zero width", width: 0, height: 1},
{name: "zero height", width: 1, height: 0},
{name: "negative width", width: -1, height: 1},
{name: "negative height", width: 1, height: -1},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
defer func() {
if recover() == nil {
t.Fatalf("NewWorld(%d, %d) did not panic", tt.width, tt.height)
}
}()
_ = NewWorld(tt.width, tt.height)
})
}
}
func TestCheckCoordinate(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
tests := []struct {
name string
xf int
yf int
want bool
}{
{name: "origin", xf: 0, yf: 0, want: true},
{name: "inside", xf: 5000, yf: 5000, want: true},
{name: "last valid", xf: 9999, yf: 9999, want: true},
{name: "x below", xf: -1, yf: 0, want: false},
{name: "y below", xf: 0, yf: -1, want: false},
{name: "x equal width", xf: 10000, yf: 0, want: false},
{name: "y equal height", xf: 0, yf: 10000, want: false},
}
for _, tt := range tests {
if got := w.checkCoordinate(tt.xf, tt.yf); got != tt.want {
t.Fatalf("checkCoordinate(%d, %d) = %v, want %v", tt.xf, tt.yf, got, tt.want)
}
}
}
func TestAddPoint(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddPoint(1.25, 2.75)
if err != nil {
t.Fatalf("AddPoint returned error: %v", err)
}
item, ok := w.objects[id]
if !ok {
t.Fatalf("point with id %v was not stored", id)
}
p, ok := item.(Point)
if !ok {
t.Fatalf("stored item type = %T, want Point", item)
}
if p.X != 1250 || p.Y != 2750 {
t.Fatalf("stored point = (%d, %d), want (1250, 2750)", p.X, p.Y)
}
}
func TestAddPointRejectsOutOfBounds(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
tests := []struct {
name string
x float64
y float64
}{
{name: "negative x", x: -0.001, y: 1},
{name: "negative y", x: 1, y: -0.001},
{name: "x rounds to width", x: 9.9995, y: 1},
{name: "y rounds to height", x: 1, y: 9.9995},
{name: "x clearly outside", x: 10, y: 1},
{name: "y clearly outside", x: 1, y: 10},
}
for _, tt := range tests {
_, err := w.AddPoint(tt.x, tt.y)
if !errors.Is(err, errBadCoordinate) {
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
}
}
}
func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddPoint(9.9994, 9.9994)
if err != nil {
t.Fatalf("AddPoint returned error: %v", err)
}
p := w.objects[id].(Point)
if p.X != 9999 || p.Y != 9999 {
t.Fatalf("stored point = (%d, %d), want (9999, 9999)", p.X, p.Y)
}
}
func TestAddCircle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddCircle(2.5, 3.5, 1.25)
if err != nil {
t.Fatalf("AddCircle returned error: %v", err)
}
item, ok := w.objects[id]
if !ok {
t.Fatalf("circle with id %v was not stored", id)
}
c, ok := item.(Circle)
if !ok {
t.Fatalf("stored item type = %T, want Circle", item)
}
if c.X != 2500 || c.Y != 3500 || c.Radius != 1250 {
t.Fatalf("stored circle = (%d, %d, %d), want (2500, 3500, 1250)", c.X, c.Y, c.Radius)
}
}
func TestAddCircleAllowsZeroRadius(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddCircle(2, 3, 0)
if err != nil {
t.Fatalf("AddCircle returned error: %v", err)
}
c := w.objects[id].(Circle)
if c.Radius != 0 {
t.Fatalf("radius = %d, want 0", c.Radius)
}
}
func TestAddCircleRejectsInvalidInput(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
if _, err := w.AddCircle(1, 1, -0.001); !errors.Is(err, errBadRadius) {
t.Fatalf("negative radius error = %v, want %v", err, errBadRadius)
}
tests := []struct {
name string
x float64
y float64
}{
{name: "negative x", x: -0.001, y: 1},
{name: "negative y", x: 1, y: -0.001},
{name: "x rounds to width", x: 9.9995, y: 1},
{name: "y rounds to height", x: 1, y: 9.9995},
}
for _, tt := range tests {
_, err := w.AddCircle(tt.x, tt.y, 1)
if !errors.Is(err, errBadCoordinate) {
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
}
}
}
func TestAddLine(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddLine(1.1, 2.2, 3.3, 4.4)
if err != nil {
t.Fatalf("AddLine returned error: %v", err)
}
item, ok := w.objects[id]
if !ok {
t.Fatalf("line with id %v was not stored", id)
}
l, ok := item.(Line)
if !ok {
t.Fatalf("stored item type = %T, want Line", item)
}
if l.X1 != 1100 || l.Y1 != 2200 || l.X2 != 3300 || l.Y2 != 4400 {
t.Fatalf("stored line = (%d, %d) -> (%d, %d), want (1100, 2200) -> (3300, 4400)",
l.X1, l.Y1, l.X2, l.Y2)
}
}
func TestAddLineRejectsInvalidInput(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
tests := []struct {
name string
x1 float64
y1 float64
x2 float64
y2 float64
}{
{name: "first point x below", x1: -0.001, y1: 1, x2: 2, y2: 2},
{name: "first point y below", x1: 1, y1: -0.001, x2: 2, y2: 2},
{name: "second point x below", x1: 1, y1: 1, x2: -0.001, y2: 2},
{name: "second point y below", x1: 1, y1: 1, x2: 2, y2: -0.001},
{name: "first point x rounds to width", x1: 9.9995, y1: 1, x2: 2, y2: 2},
{name: "second point y rounds to height", x1: 1, y1: 1, x2: 2, y2: 9.9995},
}
for _, tt := range tests {
_, err := w.AddLine(tt.x1, tt.y1, tt.x2, tt.y2)
if !errors.Is(err, errBadCoordinate) {
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
}
}
}
func TestResetGrid(t *testing.T) {
t.Parallel()
w := NewWorld(10, 6)
w.resetGrid(2 * SCALE)
if w.cellSize != 2*SCALE {
t.Fatalf("cellSize = %d, want %d", w.cellSize, 2*SCALE)
}
if w.cols != 5 {
t.Fatalf("cols = %d, want 5", w.cols)
}
if w.rows != 3 {
t.Fatalf("rows = %d, want 3", w.rows)
}
if len(w.grid) != 3 {
t.Fatalf("len(grid) = %d, want 3", len(w.grid))
}
for row := range w.grid {
if len(w.grid[row]) != 5 {
t.Fatalf("len(grid[%d]) = %d, want 5", row, len(w.grid[row]))
}
}
}
func TestWorldToCellXY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
if got := w.worldToCellX(2500); got != 1 {
t.Fatalf("worldToCellX(2500) = %d, want 1", got)
}
if got := w.worldToCellY(4500); got != 2 {
t.Fatalf("worldToCellY(4500) = %d, want 2", got)
}
if got := w.worldToCellX(-1); got != 4 {
t.Fatalf("worldToCellX(-1) = %d, want 4", got)
}
if got := w.worldToCellY(10000); got != 0 {
t.Fatalf("worldToCellY(10000) = %d, want 0", got)
}
}
func TestIndexObjectPoint(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
p := Point{Id: id, X: 2500, Y: 4500}
w.indexObject(p)
cellHasOnlyID(t, w, 2, 1, id)
cellIsEmpty(t, w, 0, 0)
cellIsEmpty(t, w, 4, 4)
}
func TestIndexObjectCircleWithoutWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900}
w.indexObject(c)
assertOccupiedCells(t, w, id,
[2]int{0, 1},
[2]int{1, 1},
)
}
func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
c := Circle{Id: id, X: 500, Y: 500, Radius: 900}
w.indexObject(c)
assertOccupiedCells(t, w, id,
[2]int{0, 0},
[2]int{0, 4},
[2]int{4, 0},
[2]int{4, 4},
)
}
func TestIndexObjectCircleCoversWholeWorld(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000}
w.indexObject(c)
want := make([][2]int, 0, 25)
for row := 0; row < 5; row++ {
for col := 0; col < 5; col++ {
want = append(want, [2]int{row, col})
}
}
assertOccupiedCells(t, w, id, want...)
}
func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000}
w.indexObject(l)
assertOccupiedCells(t, w, id,
[2]int{0, 1},
[2]int{1, 1},
[2]int{2, 1},
)
}
func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000}
w.indexObject(l)
assertOccupiedCells(t, w, id,
[2]int{1, 0},
[2]int{1, 1},
[2]int{1, 2},
)
}
func TestIndexObjectLineWrapsAcrossX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000}
w.indexObject(l)
assertOccupiedCells(t, w, id,
[2]int{1, 4},
[2]int{1, 0},
)
}
func TestIndexObjectLineWrapsAcrossY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000}
w.indexObject(l)
assertOccupiedCells(t, w, id,
[2]int{4, 1},
[2]int{0, 1},
)
}
func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000}
w.indexObject(l)
assertOccupiedCells(t, w, id,
[2]int{1, 3},
[2]int{1, 4},
[2]int{1, 0},
)
}
type unknown struct {
id uuid.UUID
}
func (u unknown) ID() uuid.UUID {
return u.id
}
func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
defer func() {
if recover() == nil {
t.Fatal("indexObject did not panic for unknown item type")
}
}()
w.indexObject(unknown{id: uuid.New()})
}
+120
View File
@@ -0,0 +1,120 @@
package world
// worldFixedToCameraZoom converts a fixed-point zoom value back into the
// UI-facing floating-point representation where 1.0 means neutral zoom.
func worldFixedToCameraZoom(zoomFp int) float64 {
return float64(zoomFp) / float64(SCALE)
}
// requiredZoomToFitWorld returns the minimum fixed-point zoom needed so that
// a viewport span of viewportSpanPx pixels does not exceed a world span of
// worldSpanFp fixed-point units.
//
// The result is rounded up, not down, because the fit constraint must be
// satisfied conservatively: after correction, the visible world span must
// never be larger than the actual world span.
func requiredZoomToFitWorld(viewportSpanPx, worldSpanFp int) int {
if viewportSpanPx < 0 {
panic("requiredZoomToFitWorld: negative viewport span")
}
if worldSpanFp <= 0 {
panic("requiredZoomToFitWorld: non-positive world span")
}
if viewportSpanPx == 0 {
return 0
}
return ceilDiv(viewportSpanPx*SCALE*SCALE, worldSpanFp)
}
// correctCameraZoomFp corrects a fixed-point zoom value using two groups
// of constraints:
//
// 1. Fit-to-world constraints derived from viewport and world sizes.
// These have the highest priority and prevent the viewport from becoming
// larger than the world on any axis, which would otherwise expose wrap
// on the visible user area.
//
// 2. Optional UI zoom bounds [minZoomFp, maxZoomFp].
// A zero bound means "ignore this bound".
// If fit-to-world requires a zoom larger than maxZoomFp, the fit constraint
// wins and maxZoomFp is ignored for that case.
//
// The function returns either the corrected zoom or currentZoomFp unchanged
// when no correction is required.
func correctCameraZoomFp(
currentZoomFp int,
viewportWidthPx, viewportHeightPx int,
worldWidthFp, worldHeightFp int,
minZoomFp, maxZoomFp int,
) int {
if currentZoomFp <= 0 {
panic("correctCameraZoomFp: non-positive current zoom")
}
if viewportWidthPx < 0 || viewportHeightPx < 0 {
panic("correctCameraZoomFp: negative viewport size")
}
if worldWidthFp <= 0 || worldHeightFp <= 0 {
panic("correctCameraZoomFp: non-positive world size")
}
if minZoomFp < 0 || maxZoomFp < 0 {
panic("correctCameraZoomFp: negative zoom bound")
}
if minZoomFp > 0 && maxZoomFp > 0 && minZoomFp > maxZoomFp {
panic("correctCameraZoomFp: min zoom greater than max zoom")
}
// Start from the user zoom.
result := currentZoomFp
// Apply min bound first (only increases zoom, always valid).
if minZoomFp > 0 && result < minZoomFp {
result = minZoomFp
}
// Apply max bound tentatively. This can be overridden later by the anti-wrap constraint.
if maxZoomFp > 0 && result > maxZoomFp {
result = maxZoomFp
}
// If viewport is larger than the world on any axis at the current result zoom,
// increase zoom to the minimum value that prevents wrap in the visible area.
requiredFitX := requiredZoomToFitWorld(viewportWidthPx, worldWidthFp)
requiredFitY := requiredZoomToFitWorld(viewportHeightPx, worldHeightFp)
requiredFit := max(requiredFitX, requiredFitY)
if requiredFit > 0 && result < requiredFit {
result = requiredFit
}
// Re-apply max bound only if it does not conflict with the anti-wrap requirement.
// If anti-wrap requires zoom > maxZoomFp, anti-wrap wins.
if maxZoomFp > 0 && result > maxZoomFp && requiredFit <= maxZoomFp {
result = maxZoomFp
}
return result
}
// CorrectCameraZoom adapts fixed-point zoom correction for UI code.
//
// currentZoom is the user-facing zoom multiplier in floating-point form.
// The result is returned in the same representation.
func (g *World) CorrectCameraZoom(
currentZoom float64,
viewportWidthPx int,
viewportHeightPx int,
) float64 {
currentZoomFp := mustCameraZoomToWorldFixed(currentZoom)
correctedZoomFp := correctCameraZoomFp(
currentZoomFp,
viewportWidthPx,
viewportHeightPx,
g.W,
g.H,
MIN_ZOOM,
MAX_ZOOM,
)
return worldFixedToCameraZoom(correctedZoomFp)
}
+442
View File
@@ -0,0 +1,442 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldFixedToCameraZoom(t *testing.T) {
t.Parallel()
tests := []struct {
name string
zoomFp int
want float64
}{
{name: "zero", zoomFp: 0, want: 0},
{name: "neutral", zoomFp: SCALE, want: 1.0},
{name: "fractional", zoomFp: 1250, want: 1.25},
{name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := worldFixedToCameraZoom(tt.zoomFp)
require.Equal(t, tt.want, got)
})
}
}
func TestRequiredZoomToFitWorld(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
want int
}{
{
name: "zero viewport span",
viewportSpanPx: 0,
worldSpanFp: 10 * SCALE,
want: 0,
},
{
name: "exact neutral fit",
viewportSpanPx: 10,
worldSpanFp: 10 * SCALE,
want: SCALE,
},
{
name: "exact 2x fit",
viewportSpanPx: 20,
worldSpanFp: 10 * SCALE,
want: 2 * SCALE,
},
{
name: "fractional fit rounded up",
viewportSpanPx: 11,
worldSpanFp: 10 * SCALE,
want: 1100,
},
{
name: "small world requires larger zoom",
viewportSpanPx: 320,
worldSpanFp: 80 * SCALE,
want: 4 * SCALE,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
require.Equal(t, tt.want, got)
})
}
}
func TestRequiredZoomToFitWorldPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
}{
{
name: "negative viewport span",
viewportSpanPx: -1,
worldSpanFp: 10 * SCALE,
},
{
name: "zero world span",
viewportSpanPx: 10,
worldSpanFp: 0,
},
{
name: "negative world span",
viewportSpanPx: 10,
worldSpanFp: -1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
})
})
}
}
func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
2*SCALE,
40, 30,
100*SCALE, 100*SCALE,
MIN_ZOOM, MAX_ZOOM,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got)
}
func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 1600,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
5*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
1250,
20, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1250, got)
}
func TestCorrectCameraZoomFpPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fn func()
}{
{
name: "non-positive current zoom",
fn: func() {
_ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport width",
fn: func() {
_ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world width",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0)
},
},
{
name: "negative min zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0)
},
},
{
name: "negative max zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1)
},
},
{
name: "min greater than max",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, tt.fn)
})
}
}
func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(1.0, 120, 20)
require.Equal(t, 1.2, got)
}
func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(100.0, 20, 20)
require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got)
}
func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) {
t.Parallel()
w := NewWorld(1, 100)
got := w.CorrectCameraZoom(1.0, 40, 10)
require.Equal(t, 40.0, got)
}
func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE, // currentZoomFp = 1.0x
80, 80, // viewport px
100*SCALE, 100*SCALE, // world fp
0, 0,
)
// No anti-wrap needed, and we do not auto-fit by lowering zoom.
require.Equal(t, SCALE, got)
}
func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) {
t.Parallel()
// World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large.
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got) // 1.2x
}
func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE, // user wants 4x
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE, // max 3x
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) {
t.Parallel()
// requiredFit = 2x, but max is 1.5x => must return 2x.
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
800, // 0.8x
20, 20,
100*SCALE, 100*SCALE,
SCALE, 0, // min 1.0x
)
require.Equal(t, SCALE, got)
}
func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}