3326 lines
94 KiB
Go
3326 lines
94 KiB
Go
package world
|
|
|
|
import (
|
|
"github.com/stretchr/testify/require"
|
|
"image"
|
|
"image/color"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// TestRenderParamsCanvasSize verifies render Params Canvas Size.
|
|
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())
|
|
}
|
|
|
|
// TestRenderParamsCameraZoomFp verifies render Params Camera Zoom Fp.
|
|
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)
|
|
}
|
|
|
|
// TestRenderParamsExpandedCanvasWorldRect verifies render Params Expanded Canvas World Rect.
|
|
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)
|
|
}
|
|
|
|
// TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera verifies render Params Expanded Canvas World Rect Allows Out Of World Camera.
|
|
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)
|
|
}
|
|
|
|
// TestRenderParamsValidate verifies render Params Validate.
|
|
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())
|
|
}
|
|
|
|
// TestRenderParamsValidateRejectsInvalidViewport verifies render Params Validate Rejects Invalid Viewport.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestRenderParamsValidateRejectsInvalidMargins verifies render Params Validate Rejects Invalid Margins.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestRenderParamsValidateRejectsInvalidCameraZoom verifies render Params Validate Rejects Invalid Camera Zoom.
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestExpandedCanvasWorldRect verifies expanded Canvas World Rect.
|
|
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)
|
|
}
|
|
|
|
// TestExpandedCanvasWorldRectPanics verifies expanded Canvas World Rect Panics.
|
|
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)
|
|
})
|
|
}
|
|
|
|
// TestWorldRenderRejectsNilDrawer verifies world Render Rejects Nil Drawer.
|
|
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)
|
|
}
|
|
|
|
// TestWorldRenderRejectsInvalidParams verifies world Render Rejects Invalid Params.
|
|
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)
|
|
}
|
|
|
|
// TestWorldRenderReturnsErrorWhenGridNotBuilt verifies world Render Returns Error When Grid Not Built.
|
|
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)
|
|
}
|
|
|
|
// TestWorldRenderStageAStubReturnsNilOnValidInput verifies world Render Stage A Stub Returns Nil On Valid Input.
|
|
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)
|
|
}
|
|
|
|
// TestTileWorldRect_NoWrapSingleTile verifies tile World Rect No Wrap Single Tile.
|
|
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)
|
|
}
|
|
|
|
// TestTileWorldRect_WrapX_TwoTiles verifies tile World Rect Wrap X Two Tiles.
|
|
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)
|
|
}
|
|
|
|
// TestTileWorldRect_WrapX_NegativeCoords verifies tile World Rect Wrap X Negative Coords.
|
|
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)
|
|
}
|
|
|
|
// TestTileWorldRect_WrapXY_FourTiles verifies tile World Rect Wrap XY Four Tiles.
|
|
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)
|
|
}
|
|
|
|
// TestTileWorldRect_EmptyRectReturnsNil verifies tile World Rect Empty Rect Returns Nil.
|
|
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))
|
|
}
|
|
|
|
// TestTileWorldRectPanicsOnInvalidWorldSize verifies tile World Rect Panics On Invalid World Size.
|
|
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) })
|
|
}
|
|
|
|
// TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt verifies collect Candidates For Tiles Returns Error When Grid Not Built.
|
|
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)
|
|
}
|
|
|
|
// TestCollectCandidatesForTileDedupsWithinOneTile verifies collect Candidates For Tile Dedups Within One Tile.
|
|
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())
|
|
}
|
|
|
|
// TestCollectCandidatesForTileReturnsPointInCoveredCell verifies collect Candidates For Tile Returns Point In Covered Cell.
|
|
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)
|
|
}
|
|
|
|
// TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides verifies collect Candidates For Tiles Wrap Indexed Circle Appears In Both Sides.
|
|
func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.SetCircleRadiusScaleFp(SCALE)
|
|
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())
|
|
}
|
|
|
|
// TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas verifies build Render Plan Stage A Single Tile Clip Is Whole Canvas.
|
|
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.buildRenderPlan(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)
|
|
}
|
|
|
|
// TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps verifies build Render Plan Stage A Tiles Cover Canvas Without Gaps.
|
|
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.buildRenderPlan(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)
|
|
}
|
|
|
|
// TestBuildRenderPlanStageA_CandidatesArePerTileDeduped verifies build Render Plan Stage A Candidates Are Per Tile Deduped.
|
|
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.buildRenderPlan(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[PrimitiveID]struct{}{}
|
|
for _, it := range td.Candidates {
|
|
_, ok := seen[it.ID()]
|
|
require.False(t, ok, "candidate duplicated within a tile")
|
|
seen[it.ID()] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWorldForceFullRedrawNextResetsIncrementalState verifies world Force Full Redraw Next Resets Incremental State.
|
|
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)
|
|
}
|
|
|
|
// TestRender_SortsByPriorityWithinTile verifies render Sorts By Priority Within Tile.
|
|
func TestRender_SortsByPriorityWithinTile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Same tile. Priorities deliberately mixed.
|
|
_, err := w.AddCircle(5, 5, 1, CircleWithPriority(500))
|
|
require.NoError(t, err)
|
|
|
|
_, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100))
|
|
require.NoError(t, err)
|
|
|
|
_, err = w.AddPoint(5, 6, PointWithPriority(300))
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
// default: wrap on
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
// We verify the first occurrence of each primitive kind follows priority order.
|
|
// Since each object is drawn with Add* + Fill/Stroke immediately, order should match.
|
|
cmds := d.Commands()
|
|
firstLine := indexOfFirst(cmds, "AddLine")
|
|
firstCircle := indexOfFirst(cmds, "AddCircle")
|
|
firstPoint := indexOfFirst(cmds, "AddPoint")
|
|
|
|
require.NotEqual(t, -1, firstLine)
|
|
require.NotEqual(t, -1, firstCircle)
|
|
require.NotEqual(t, -1, firstPoint)
|
|
|
|
require.Less(t, firstLine, firstPoint)
|
|
require.Less(t, firstPoint, firstCircle) // 300 before 500
|
|
}
|
|
|
|
func indexOfFirst(cmds []fakeDrawerCommand, name string) int {
|
|
for i, c := range cmds {
|
|
if c.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
type bgOffsetScaleTheme struct {
|
|
img image.Image
|
|
anchor BackgroundAnchorMode
|
|
}
|
|
|
|
func (t bgOffsetScaleTheme) ID() string { return "bgoffset" }
|
|
func (t bgOffsetScaleTheme) Name() string { return "bgoffset" }
|
|
|
|
func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
|
func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img }
|
|
|
|
func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
|
|
func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
|
func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
|
|
|
|
func (t bgOffsetScaleTheme) PointStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
|
}
|
|
func (t bgOffsetScaleTheme) LineStyle() Style {
|
|
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
func (t bgOffsetScaleTheme) CircleStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
|
|
func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
|
|
// TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan verifies render Background Tile Repeat World Anchored Shifts With Pan.
|
|
func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(20, 20)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4
|
|
w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 8,
|
|
ViewportHeightPx: 8,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
// First render.
|
|
d1 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d1, params))
|
|
|
|
minX1, minY1 := minDrawImageXY(t, d1)
|
|
require.Equal(t, -1, minX1)
|
|
require.Equal(t, -1, minY1)
|
|
|
|
// Pan camera by +1 world unit along both axes (zoom=1 => 1px).
|
|
params2 := params
|
|
params2.CameraXWorldFp += 1 * SCALE
|
|
params2.CameraYWorldFp += 1 * SCALE
|
|
|
|
// Force full redraw to make this test independent of incremental pipeline.
|
|
w.ForceFullRedrawNext()
|
|
|
|
d2 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d2, params2))
|
|
|
|
minX2, minY2 := minDrawImageXY(t, d2)
|
|
|
|
// With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size).
|
|
require.Equal(t, -2, minX2)
|
|
require.Equal(t, -2, minY2)
|
|
}
|
|
|
|
// TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan verifies render Background Tile Repeat Viewport Anchored Does Not Shift With Pan.
|
|
func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(20, 20)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 8,
|
|
ViewportHeightPx: 8,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d1 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d1, params))
|
|
|
|
minX1, minY1 := minDrawImageXY(t, d1)
|
|
|
|
params2 := params
|
|
params2.CameraXWorldFp += 1 * SCALE
|
|
params2.CameraYWorldFp += 1 * SCALE
|
|
|
|
w.ForceFullRedrawNext()
|
|
|
|
d2 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d2, params2))
|
|
|
|
minX2, minY2 := minDrawImageXY(t, d2)
|
|
|
|
// With viewport anchoring, tiling origin is fixed (no camera dependency).
|
|
require.Equal(t, minX1, minX2)
|
|
require.Equal(t, minY1, minY2)
|
|
}
|
|
|
|
func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) {
|
|
t.Helper()
|
|
|
|
cmds := d.CommandsByName("DrawImage")
|
|
require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling")
|
|
|
|
minX := int(cmds[0].Args[0])
|
|
minY := int(cmds[0].Args[1])
|
|
|
|
for _, c := range cmds[1:] {
|
|
x := int(c.Args[0])
|
|
y := int(c.Args[1])
|
|
if x < minX {
|
|
minX = x
|
|
}
|
|
if y < minY {
|
|
minY = y
|
|
}
|
|
}
|
|
return minX, minY
|
|
}
|
|
|
|
type bgOffsetTheme struct {
|
|
img image.Image
|
|
scaleMode BackgroundScaleMode
|
|
}
|
|
|
|
func (t bgOffsetTheme) ID() string { return "bgscale" }
|
|
func (t bgOffsetTheme) Name() string { return "bgscale" }
|
|
|
|
func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
|
func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img }
|
|
|
|
func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
|
func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
|
func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
|
|
|
|
func (t bgOffsetTheme) PointStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
|
}
|
|
func (t bgOffsetTheme) LineStyle() Style {
|
|
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
func (t bgOffsetTheme) CircleStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
|
|
func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
|
|
// TestRender_BackgroundScaleNone_UsesOffsetDrawImage verifies render Background Scale None Uses Offset Draw Image.
|
|
func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 2,
|
|
MarginYPx: 2,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
require.NotEmpty(t, d.CommandsByName("DrawImage"))
|
|
require.Empty(t, d.CommandsByName("DrawImageScaled"))
|
|
}
|
|
|
|
// TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled verifies render Background Scale Fit Uses Draw Offset Image Scaled.
|
|
func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 2,
|
|
MarginYPx: 2,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
|
|
}
|
|
|
|
type bgScaleTheme struct {
|
|
img image.Image
|
|
scaleMode BackgroundScaleMode
|
|
}
|
|
|
|
func (t bgScaleTheme) ID() string { return "bgscale" }
|
|
func (t bgScaleTheme) Name() string { return "bgscale" }
|
|
|
|
func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
|
func (t bgScaleTheme) BackgroundImage() image.Image { return t.img }
|
|
|
|
func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
|
func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
|
func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
|
|
|
|
func (t bgScaleTheme) PointStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
|
}
|
|
func (t bgScaleTheme) LineStyle() Style {
|
|
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
func (t bgScaleTheme) CircleStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
|
|
func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
|
|
// TestRender_BackgroundScaleNone_UsesDrawImage verifies render Background Scale None Uses Draw Image.
|
|
func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 2,
|
|
MarginYPx: 2,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
require.NotEmpty(t, d.CommandsByName("DrawImage"))
|
|
require.Empty(t, d.CommandsByName("DrawImageScaled"))
|
|
}
|
|
|
|
// TestRender_BackgroundScaleFit_UsesDrawImageScaled verifies render Background Scale Fit Uses Draw Image Scaled.
|
|
func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit})
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 2,
|
|
MarginYPx: 2,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
|
|
}
|
|
|
|
type bgTheme struct {
|
|
img image.Image
|
|
}
|
|
|
|
func (t bgTheme) ID() string { return "bg" }
|
|
func (t bgTheme) Name() string { return "bg" }
|
|
|
|
func (t bgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
|
func (t bgTheme) BackgroundImage() image.Image { return t.img }
|
|
|
|
func (t bgTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
|
|
func (t bgTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
|
func (t bgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
|
|
|
|
func (t bgTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
|
|
func (t bgTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
|
|
func (t bgTheme) CircleStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
|
|
func (t bgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t bgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
|
|
func (t bgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
|
|
// TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw verifies render Background Image Draws Before Primitives Full Redraw.
|
|
func TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// 4x4 opaque image
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgTheme{img: img})
|
|
|
|
_, err := w.AddPoint(5, 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{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.Commands()
|
|
iClear := indexOfFirstName(cmds, "ClearAllTo")
|
|
iBg := indexOfFirstName(cmds, "DrawImage")
|
|
iPrim := indexOfFirstName(cmds, "AddPoint")
|
|
|
|
require.NotEqual(t, -1, iClear)
|
|
require.NotEqual(t, -1, iBg)
|
|
require.NotEqual(t, -1, iPrim)
|
|
require.Less(t, iClear, iBg)
|
|
require.Less(t, iBg, iPrim)
|
|
}
|
|
|
|
// TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift verifies render Background Image Redrawn In Dirty Rects On Incremental Shift.
|
|
func TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
w.SetTheme(bgTheme{img: img})
|
|
|
|
// Ensure state: first full render commits.
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 2,
|
|
MarginYPx: 2,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
Incremental: &IncrementalPolicy{
|
|
AllowShiftOnly: false,
|
|
MaxCatchUpAreaPx: 0,
|
|
RenderBudgetMs: 0,
|
|
CoalesceUpdates: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
// Move camera by 1px right in world (zoom=1 => 1px == 1 unit).
|
|
params2 := params
|
|
params2.CameraXWorldFp += 1 * SCALE
|
|
|
|
d2 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d2, params2))
|
|
|
|
// In incremental shift path we must see ClearRectTo and DrawImage.
|
|
require.NotEmpty(t, d2.CommandsByName("CopyShift"))
|
|
require.NotEmpty(t, d2.CommandsByName("ClearRectTo"))
|
|
require.NotEmpty(t, d2.CommandsByName("DrawImage"))
|
|
}
|
|
|
|
// TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips verifies draw Circles From Plan Duplicates Across Tiles And Clips.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestDrawCirclesFromPlan_SkipsTilesWithoutCircles verifies draw Circles From Plan Skips Tiles Without Circles.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
|
|
|
// No circles => no commands.
|
|
require.Empty(t, d.Commands())
|
|
}
|
|
|
|
// TestDrawCirclesFromPlan_ProjectsRadiusWithZoom verifies draw Circles From Plan Projects Radius With Zoom.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
|
|
|
// 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])
|
|
}
|
|
}
|
|
|
|
// TestCircles_NoWrap_DoesNotDuplicateAcrossEdges verifies circles No Wrap Does Not Duplicate Across Edges.
|
|
func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.SetCircleRadiusScaleFp(SCALE)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
_, err := w.AddCircle(9, 9, 2)
|
|
require.NoError(t, err)
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
DisableWrapScroll: true,
|
|
Layers: []RenderLayer{RenderLayerCircles},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.CommandsByName("AddCircle")
|
|
require.Len(t, cmds, 1)
|
|
|
|
// Center must be at (9,9) only, no (-1,*) or (*,-1).
|
|
require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
|
|
}
|
|
|
|
// TestRender_CircleTransparentFill_UsesStrokeNotFill verifies render Circle Transparent Fill Uses Stroke Not Fill.
|
|
func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
sw := 4.0
|
|
circleStyle := w.AddStyleCircle(StyleOverride{
|
|
FillColor: color.RGBA{A: 0}, // explicitly transparent
|
|
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
|
StrokeWidthPx: &sw,
|
|
})
|
|
|
|
_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100))
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
BackgroundColor: color.RGBA{A: 255},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.Commands()
|
|
|
|
iAdd := indexOfFirstName(cmds, "AddCircle")
|
|
require.NotEqual(t, -1, iAdd)
|
|
|
|
// After AddCircle we must see Stroke (not Fill).
|
|
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds)))
|
|
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds)))
|
|
|
|
require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()")
|
|
require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible")
|
|
}
|
|
|
|
// TestRender_CircleFillAndStroke_DrawsFillThenStroke verifies render Circle Fill And Stroke Draws Fill Then Stroke.
|
|
func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
sw := 2.0
|
|
styleID := w.AddStyleCircle(StyleOverride{
|
|
FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255},
|
|
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
|
StrokeWidthPx: &sw,
|
|
})
|
|
|
|
_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100))
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
BackgroundColor: color.RGBA{A: 255},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.Commands()
|
|
iAdd := indexOfFirstName(cmds, "AddCircle")
|
|
require.NotEqual(t, -1, iAdd)
|
|
|
|
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds)))
|
|
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds)))
|
|
|
|
require.NotEqual(t, -1, iFill, "expected Fill() for visible fill")
|
|
require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke")
|
|
require.Less(t, iFill, iStroke, "Stroke must be last when both are visible")
|
|
}
|
|
|
|
// TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies circles Wrap Copies Appear Inside Viewport When Viewport Equals World.
|
|
func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// World 10x10 units => 10px at zoom=1 when viewport==world.
|
|
w := NewWorld(10, 10)
|
|
w.SetCircleRadiusScaleFp(SCALE)
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp)
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop verifies render Shift Only Over Budget Defers Dirty And Catches Up On Stop.
|
|
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)
|
|
}
|
|
|
|
// TestRender_CatchUpWhilePanning_WhenBackUnderBudget verifies render Catch Up While Panning When Back Under Budget.
|
|
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")
|
|
}
|
|
|
|
// TestRender_CatchUpLimit_ReducesPendingDirtyGradually verifies render Catch Up Limit Reduces Pending Dirty Gradually.
|
|
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)
|
|
}
|
|
|
|
// TestTakeCatchUpRects_RespectsAreaLimit verifies take Catch Up Rects Respects Area Limit.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_NoOp verifies plan Incremental Pan No Op.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_FullRedrawOnInvalidCanvas verifies plan Incremental Pan Full Redraw On Invalid Canvas.
|
|
func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0)
|
|
require.ErrorIs(t, err, errInvalidCanvasSize)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_FullRedrawOnTooLargeShift verifies plan Incremental Pan Full Redraw On Too Large Shift.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero verifies plan Incremental Pan Full Redraw When Margin Is Zero And Delta Non Zero.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX verifies plan Incremental Pan Full Redraw When Exceeds Threshold X.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY verifies plan Incremental Pan Full Redraw When Exceeds Threshold Y.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive verifies plan Incremental Pan Shift Left Strip When Dx Positive.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_Shift_RightStripWhenDxNegative verifies plan Incremental Pan Shift Right Strip When Dx Negative.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_Shift_TopStripWhenDyPositive verifies plan Incremental Pan Shift Top Strip When Dy Positive.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative verifies plan Incremental Pan Shift Bottom Strip When Dy Negative.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects verifies plan Incremental Pan Shift Diagonal Returns Two Dirty Rects.
|
|
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)
|
|
}
|
|
|
|
// TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel verifies plan Incremental Pan Overdraws Dirty Strips By One Pixel.
|
|
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)
|
|
}
|
|
|
|
// TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips verifies render Pan Small Uses Copy Shift And Renders Only Dirty Strips.
|
|
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"))
|
|
}
|
|
|
|
// TestRender_PanTooLarge_FallsBackToFullRedraw verifies render Pan Too Large Falls Back To Full Redraw.
|
|
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"))
|
|
|
|
// Full redraw should clear the entire canvas.
|
|
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
|
|
|
|
// And should draw something (at least the point).
|
|
// Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well.
|
|
require.NotEmpty(t, d.CommandsByName("AddPoint"))
|
|
}
|
|
|
|
// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive verifies world Delta Fixed To Canvas Px Remainder Accumulates Positive.
|
|
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)
|
|
}
|
|
|
|
// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative verifies world Delta Fixed To Canvas Px Remainder Accumulates Negative.
|
|
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)
|
|
}
|
|
|
|
// TestComputePanShiftPx_FirstCallRequiresFullRedraw verifies compute Pan Shift Px First Call Requires Full Redraw.
|
|
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)
|
|
}
|
|
|
|
// TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw verifies compute Pan Shift Px Zoom Or Viewport Change Forces Full Redraw.
|
|
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)
|
|
}
|
|
|
|
// TestComputePanShiftPx_PanRightShiftsImageLeft verifies compute Pan Shift Px Pan Right Shifts Image Left.
|
|
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)
|
|
}
|
|
|
|
// TestComputePanShiftPx_PanUpShiftsImageDown verifies compute Pan Shift Px Pan Up Shifts Image Down.
|
|
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)
|
|
}
|
|
|
|
// TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel verifies compute Pan Shift Px Sub Pixel Pan Accumulates To One Pixel.
|
|
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)
|
|
}
|
|
|
|
// TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits verifies torus Shortest Line Segments Tie Case Is Deterministic And Splits.
|
|
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])
|
|
}
|
|
|
|
// TestLines_NoWrap_TieCaseDoesNotWrap verifies lines No Wrap Tie Case Does Not Wrap.
|
|
func TestLines_NoWrap_TieCaseDoesNotWrap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Tie-case along X: 1 -> 6 in world of 10.
|
|
_, err := w.AddLine(1, 5, 6, 5)
|
|
require.NoError(t, err)
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
DisableWrapScroll: true,
|
|
Layers: []RenderLayer{RenderLayerLines},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
lines := d.CommandsByName("AddLine")
|
|
require.Len(t, lines, 1)
|
|
|
|
// At zoom=1 and margin=0, world==canvas, so pixels equal world units.
|
|
require.Equal(t, []float64{1, 5, 6, 5}, lines[0].Args)
|
|
}
|
|
|
|
// TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips verifies draw Points From Plan Duplicates Across Tiles And Clips.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawPointsFromPlan(d, plan, true)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestDrawPointsFromPlan_SkipsTilesWithoutPoints verifies draw Points From Plan Skips Tiles Without Points.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawPointsFromPlan(d, plan, true)
|
|
|
|
// No points => no drawing commands at all.
|
|
require.Empty(t, d.Commands())
|
|
}
|
|
|
|
// TestWorldRender_PointsOnlyStageA verifies world Render Points Only Stage A.
|
|
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")
|
|
}
|
|
|
|
// TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies points Wrap Copies Appear Inside Viewport When Viewport Equals World.
|
|
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.buildRenderPlan(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, true)
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWorldRender_DrawsAllLayersInDefaultOrder verifies world Render Draws All Layers In Default Order.
|
|
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")
|
|
}
|
|
|
|
// TestSmoke_DrawPointsAndCirclesFromSamePlan verifies smoke Draw Points And Circles From Same Plan.
|
|
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.buildRenderPlan(params)
|
|
require.NoError(t, err)
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
drawPointsFromPlan(d, plan, true)
|
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
|
|
|
names := d.CommandNames()
|
|
require.Contains(t, names, "AddPoint")
|
|
require.Contains(t, names, "AddCircle")
|
|
}
|
|
|
|
// TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile verifies render Applies Style Before Add Commands For First Item In Tile.
|
|
func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Create a derived circle style so we can observe a style application transition.
|
|
red := color.RGBA{R: 255, A: 255}
|
|
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
|
|
|
|
_, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100))
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
BackgroundColor: color.RGBA{A: 255},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.Commands()
|
|
|
|
iSetFill := indexOfFirstName(cmds, "SetFillColor")
|
|
iAddCircle := indexOfFirstName(cmds, "AddCircle")
|
|
require.NotEqual(t, -1, iSetFill)
|
|
require.NotEqual(t, -1, iAddCircle)
|
|
|
|
require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle")
|
|
}
|
|
|
|
// TestRender_DoesNotReapplySameStyleAcrossMultipleObjects verifies render Does Not Reapply Same Style Across Multiple Objects.
|
|
func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Two lines with the same default line style and same priority.
|
|
_, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100))
|
|
require.NoError(t, err)
|
|
_, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
BackgroundColor: color.RGBA{A: 255},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
// We expect style application at least once.
|
|
setWidth := d.CommandsByName("SetLineWidth")
|
|
require.NotEmpty(t, setWidth)
|
|
|
|
// The key batching assertion: style setters should not be called twice *between* two AddLine calls.
|
|
cmds := d.Commands()
|
|
line1 := indexOfFirstName(cmds, "AddLine")
|
|
require.NotEqual(t, -1, line1)
|
|
|
|
line2 := indexOfNextName(cmds, "AddLine", line1+1)
|
|
require.NotEqual(t, -1, line2)
|
|
|
|
// Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset,
|
|
// because StyleID is the same and the renderer caches lastStyleID.
|
|
for i := line1 + 1; i < line2; i++ {
|
|
switch cmds[i].Name {
|
|
case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor":
|
|
t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRender_ReappliesStyleWhenStyleIDChanges verifies render Reapplies Style When Style ID Changes.
|
|
func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Two circles, different derived fill colors => different StyleIDs.
|
|
red := color.RGBA{R: 255, A: 255}
|
|
green := color.RGBA{G: 255, A: 255}
|
|
|
|
styleRed := w.AddStyleCircle(StyleOverride{FillColor: red})
|
|
styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green})
|
|
|
|
_, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100))
|
|
require.NoError(t, err)
|
|
_, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101))
|
|
require.NoError(t, err)
|
|
|
|
for _, obj := range w.objects {
|
|
w.indexObject(obj)
|
|
}
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 10,
|
|
ViewportHeightPx: 10,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
Options: &RenderOptions{
|
|
BackgroundColor: color.RGBA{A: 255},
|
|
},
|
|
}
|
|
|
|
d := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d, params))
|
|
|
|
cmds := d.Commands()
|
|
firstCircle := indexOfFirstName(cmds, "AddCircle")
|
|
secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1)
|
|
require.NotEqual(t, -1, firstCircle)
|
|
require.NotEqual(t, -1, secondCircle)
|
|
|
|
// There must be at least one SetFillColor before each circle.
|
|
// And importantly, we expect a SetFillColor BETWEEN the two circles due to style change.
|
|
setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle)
|
|
require.NotEqual(t, -1, setBeforeFirst)
|
|
|
|
setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle)
|
|
require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs")
|
|
}
|
|
|
|
/* ---------- helper functions for fake command slices ---------- */
|
|
|
|
func indexOfFirstName(cmds []fakeDrawerCommand, name string) int {
|
|
for i, c := range cmds {
|
|
if c.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int {
|
|
for i := start; i < len(cmds); i++ {
|
|
if cmds[i].Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int {
|
|
if before > len(cmds) {
|
|
before = len(cmds)
|
|
}
|
|
for i := before - 1; i >= 0; i-- {
|
|
if cmds[i].Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int {
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if end > len(cmds) {
|
|
end = len(cmds)
|
|
}
|
|
for i := start; i < end; i++ {
|
|
if cmds[i].Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
type pointRadiusTheme struct {
|
|
id string
|
|
radius float64
|
|
}
|
|
|
|
func (t pointRadiusTheme) ID() string { return t.id }
|
|
func (t pointRadiusTheme) Name() string { return t.id }
|
|
|
|
func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
|
func (t pointRadiusTheme) BackgroundImage() image.Image { return nil }
|
|
|
|
func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
|
func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
|
func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
|
|
|
|
func (t pointRadiusTheme) PointStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius}
|
|
}
|
|
func (t pointRadiusTheme) LineStyle() Style {
|
|
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
func (t pointRadiusTheme) CircleStyle() Style {
|
|
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
|
}
|
|
|
|
func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
|
return StyleOverride{}, false
|
|
}
|
|
|
|
// TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles verifies render Theme Change Applies Without Reindex Uses Latest Object Styles.
|
|
func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
// Build index once.
|
|
w.IndexOnViewportChange(100, 100, 1.0)
|
|
|
|
// Theme A: point radius 2
|
|
w.SetTheme(pointRadiusTheme{id: "A", radius: 2})
|
|
|
|
_, err := w.AddPoint(5, 5)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state).
|
|
// Render once.
|
|
params := RenderParams{
|
|
ViewportWidthPx: 100,
|
|
ViewportHeightPx: 100,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
d1 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d1, params))
|
|
|
|
p1 := d1.CommandsByName("AddPoint")
|
|
require.NotEmpty(t, p1)
|
|
r1 := p1[0].Args[2]
|
|
require.Equal(t, 2.0, r1)
|
|
|
|
// Theme B: point radius 7. Change theme, but DO NOT reindex.
|
|
w.SetTheme(pointRadiusTheme{id: "B", radius: 7})
|
|
|
|
d2 := &fakePrimitiveDrawer{}
|
|
require.NoError(t, w.Render(d2, params))
|
|
|
|
p2 := d2.CommandsByName("AddPoint")
|
|
require.NotEmpty(t, p2)
|
|
r2 := p2[0].Args[2]
|
|
require.Equal(t, 7.0, r2)
|
|
}
|