world refactor

This commit is contained in:
Ilia Denisov
2026-03-17 11:48:05 +02:00
committed by GitHub
parent 9208ef1065
commit 5029857fe4
82 changed files with 9838 additions and 9715 deletions
+1 -5
View File
@@ -99,10 +99,6 @@ func (l *loader) runOnce(ctx context.Context) error {
l.logText(fmt.Sprintf("Starting UI client v%s", target.Version))
l.logText(fmt.Sprintf("Executable: %s", target.Path))
fyne.Do(func() {
l.debugWindow.Hide()
})
exitCode, runErr := l.runner.Run(ctx, target.Path)
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
@@ -124,7 +120,7 @@ func (l *loader) init(ctx context.Context) {
l.btn.Hide()
l.btn.Disable()
// show debugWindow can be done with future debug mode, e.g. with -debug flag
// l.window.Show()
l.debugWindow.Hide()
})
err := l.runOnce(ctx)
+158
View File
@@ -0,0 +1,158 @@
# World rendering package
## Purpose
`world` is the client-side map model and renderer for a 2D world that normally
behaves like a torus. It owns:
- primitive storage (`Point`, `Line`, `Circle`)
- world-space indexing for render and hit-test queries
- theme and style resolution
- full-frame and incremental rendering onto an expanded canvas
- no-wrap helpers used by the UI when torus scrolling is disabled
The package does not own UI widgets, event loops, or camera policy beyond the
helpers exposed for zoom/clamp calculations.
## Symbol Map
- World creation and mutation: `NewWorld`, `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `Reindex`
- Viewport/index lifecycle: `IndexOnViewportChange`, `SetCircleRadiusScaleFp`
- Rendering: `Render`, `RenderParams`, `RenderOptions`, `PrimitiveDrawer`, `GGDrawer`
- No-wrap camera helpers: `CorrectCameraZoom`, `ClampCameraNoWrapViewport`, `ClampRenderParamsNoWrap`, `PivotZoomCameraNoWrap`
- Hit testing: `HitTest`, `Hit`, `PrimitiveKind`
- Styling and themes: `Style`, `StyleOverride`, `StyleTable`, `StyleTheme`, `DefaultTheme`, `ThemeLight`, `ThemeDark`
## Coordinate Model
- World geometry is stored in fixed-point integers.
- `SCALE == 1000`, so `1.0` world units are represented as `1000`.
- Primitive coordinates, radii, world dimensions, and camera positions use `world-fixed` units.
- Viewport and canvas sizes use integer `canvas px`.
- Rectangles in world space and canvas space are treated as half-open intervals:
`[minX, maxX) x [minY, maxY)`.
- `RenderParams` describes the visible viewport, but rendering happens on the
expanded canvas:
- `canvasWidthPx = viewportWidthPx + 2*marginXPx`
- `canvasHeightPx = viewportHeightPx + 2*marginYPx`
- The camera always points to the center of the visible viewport, not the center
of the expanded canvas.
## Data Model
- `World` stores torus dimensions `W` and `H` in fixed-point units.
- `MapItem` is implemented by `Point`, `Line`, and `Circle`.
- `PrimitiveID` is allocated by `World` and may be reused after removal.
- Each primitive carries:
- geometry in fixed-point world coordinates
- `Priority` for deterministic draw order inside a tile
- resolved `StyleID`
- theme binding metadata (`Base`, `Override`, `Class`)
- optional per-primitive hit slop in pixels
- Themes resolve base styles per primitive kind, then optional class overrides,
then optional user `StyleOverride`.
- Explicit `StyleID` bypasses theme-relative recomputation across theme changes.
## Spatial Index Lifecycle
- Rendering and hit testing depend on the grid index stored in `World.grid`.
- `IndexOnViewportChange` must be called after viewport size or zoom changes.
- The grid cell size is derived from the current visible world span:
- start from roughly `visibleMin / 8`
- clamp into `[16*SCALE, 512*SCALE]`
- `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `SetCircleRadiusScaleFp`, and
`Reindex` mark the index dirty and rebuild it automatically when the last
viewport/zoom state is known.
- Circle indexing uses the effective radius after `circleRadiusScaleFp` is applied.
- Line indexing uses the torus-shortest representation and indexes its wrapped
bounding boxes rather than exact rasterized coverage.
## Render Pipeline
`Render` follows this sequence:
1. Validate `RenderParams` and resolve background color/theme state.
2. Convert zoom to fixed-point and compute the expanded unwrapped world rect.
3. Split that rect into `WorldTile` segments:
- torus mode uses wrapped tiling
- no-wrap mode intersects against the bounded world once
4. Query the spatial grid per tile and deduplicate candidates per tile by `PrimitiveID`.
5. Build a `RenderPlan` containing:
- tile-to-canvas clip rectangles
- per-tile candidate lists
6. Draw background before primitives.
7. Draw primitives tile-by-tile in deterministic order:
- `Priority` ascending
- primitive kind as stable tie-breaker
- `PrimitiveID` ascending
8. For wrapped rendering:
- points and circles emit only the torus copies that intersect the current tile
- lines are split into torus-shortest canonical segments before projection
## Incremental Pan Rendering
- `Render` first tries incremental pan reuse through `ComputePanShiftPx` and
`PlanIncrementalPan`.
- If only camera pan changed and the shift stays inside the configured margins:
- existing pixels are moved with `PrimitiveDrawer.CopyShift`
- newly exposed strips become dirty rects
- dirty rects are cleared, background-redrawn, and clipped primitive redraw is applied
- If geometry changed in a way that breaks reuse, rendering falls back to full redraw.
- Theme changes, circle radius scale changes, and explicit `ForceFullRedrawNext`
reset incremental state.
## No-Wrap Behavior
When `RenderOptions.DisableWrapScroll == true`, the world is treated as a bounded
plane instead of a torus.
- `CorrectCameraZoom` prevents the visible viewport from becoming larger than the world.
- `ClampCameraNoWrapViewport` clamps the camera so the viewport remains inside the world.
- `ClampRenderParamsNoWrap` applies the same rule directly to `RenderParams`.
- `PivotZoomCameraNoWrap` keeps the world point under the cursor stable while zoom changes.
Margins are ignored by viewport clamp on purpose so panning remains usable even
when the expanded canvas extends beyond the world bounds.
## Hit Testing
- `HitTest` expects the grid to be built already.
- Cursor coordinates are passed in viewport pixels relative to the viewport top-left.
- The query path is:
1. convert cursor position into world-fixed coordinates
2. clamp or wrap based on no-wrap mode
3. query a conservative grid search box using default hit slop
4. run exact per-primitive hit checks
- Point hits use disc distance.
- Circle hits distinguish between filled circles and stroke-only rings.
- Line hits use the same torus-shortest segment decomposition as rendering.
- Final ranking is:
- `Priority` descending
- squared distance ascending
- primitive kind ascending
- `PrimitiveID` ascending
## UI Integration Checklist
Typical UI flow:
1. Create the world with `NewWorld`.
2. Add primitives and optional styles/themes.
3. Before each render, compute the current viewport size in pixels.
4. Call `CorrectCameraZoom` when UI zoom changes.
5. Call `IndexOnViewportChange` when viewport size or zoom changes.
6. If no-wrap mode is enabled, call `ClampRenderParamsNoWrap`.
7. Render into a `PrimitiveDrawer` with `Render`.
8. Reuse the same `RenderParams` snapshot for `HitTest`.
The `client` package in this repository follows exactly that pattern.
## Important Invariants and Limits
- `Render` and `HitTest` require the grid to be initialized; otherwise they return `errGridNotBuilt`.
- The package assumes single-goroutine access to hot render scratch buffers stored in `World`.
- `RenderScheduler` is only a coalescing example. It is not a license to call
`Render` on arbitrary background goroutines in real UI code.
- `PrimitiveDrawer` receives final canvas coordinates only; all torus math stays inside `world`.
- Background anchoring can be viewport-relative or world-relative, but dirty redraws
always use the same anchoring logic as full redraws.
-117
View File
@@ -1,117 +0,0 @@
package world
// ClampCameraNoWrapViewport clamps camera center so that the VIEWPORT world-rect
// stays within the bounded world [0..worldW) x [0..worldH), when possible.
//
// This is the correct clamp for user panning when wrap is disabled.
// Margins (expanded canvas) are intentionally ignored here; they may extend outside the world.
func ClampCameraNoWrapViewport(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
zoomFp int,
worldW, worldH int,
) (int, int) {
if zoomFp <= 0 {
panic("ClampCameraNoWrapViewport: invalid zoom")
}
if viewportW < 0 || viewportH < 0 {
panic("ClampCameraNoWrapViewport: negative viewport")
}
if worldW <= 0 || worldH <= 0 {
panic("ClampCameraNoWrapViewport: invalid world size")
}
spanW := PixelSpanToWorldFixed(viewportW, zoomFp)
spanH := PixelSpanToWorldFixed(viewportH, zoomFp)
halfW := spanW / 2
halfH := spanH / 2
cameraXWorldFp = clampCameraAxis(cameraXWorldFp, worldW, halfW)
cameraYWorldFp = clampCameraAxis(cameraYWorldFp, worldH, halfH)
return cameraXWorldFp, cameraYWorldFp
}
// ClampCameraNoWrapExpanded clamps camera center so that the EXPANDED CANVAS world-rect
// (viewport + margins) stays within the bounded world, when possible.
//
// This is stricter than viewport-based clamp and can prevent panning when margins are large.
func ClampCameraNoWrapExpanded(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
marginX, marginY int,
zoomFp int,
worldW, worldH int,
) (int, int) {
if zoomFp <= 0 {
panic("ClampCameraNoWrapExpanded: invalid zoom")
}
if viewportW < 0 || viewportH < 0 || marginX < 0 || marginY < 0 {
panic("ClampCameraNoWrapExpanded: negative sizes")
}
if worldW <= 0 || worldH <= 0 {
panic("ClampCameraNoWrapExpanded: invalid world size")
}
canvasW := viewportW + 2*marginX
canvasH := viewportH + 2*marginY
spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
spanH := PixelSpanToWorldFixed(canvasH, zoomFp)
halfW := spanW / 2
halfH := spanH / 2
cameraXWorldFp = clampCameraAxis(cameraXWorldFp, worldW, halfW)
cameraYWorldFp = clampCameraAxis(cameraYWorldFp, worldH, halfH)
return cameraXWorldFp, cameraYWorldFp
}
func clampCameraAxis(cam, worldSize, halfSpan int) int {
// If viewport/span does not fit: force center.
if 2*halfSpan > worldSize {
return worldSize / 2
}
minCam := halfSpan
maxCam := worldSize - halfSpan
if cam < minCam {
return minCam
}
if cam > maxCam {
return maxCam
}
return cam
}
// ClampRenderParamsNoWrap clamps camera center in-place when wrap is disabled.
// It uses viewport-based clamp (NOT expanded) so panning remains possible even with margins.
func (w *World) ClampRenderParamsNoWrap(p *RenderParams) {
if p == nil {
return
}
allowWrap := true
if p.Options != nil && p.Options.DisableWrapScroll {
allowWrap = false
}
if allowWrap {
return
}
zoomFp, err := p.CameraZoomFp()
if err != nil || zoomFp <= 0 {
return
}
cx, cy := ClampCameraNoWrapViewport(
p.CameraXWorldFp, p.CameraYWorldFp,
p.ViewportWidthPx, p.ViewportHeightPx,
zoomFp,
w.W, w.H,
)
p.CameraXWorldFp = cx
p.CameraYWorldFp = cy
}
-92
View File
@@ -1,92 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld(t *testing.T) {
t.Parallel()
worldW := 100 * SCALE
worldH := 100 * SCALE
zoomFp := SCALE // 1.0x
// viewport 40px => span 40 units => half 20.
viewportW, viewportH := 40, 40
// Too far left/up => clamp to minCam=20
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 20*SCALE, cx)
require.Equal(t, 20*SCALE, cy)
// Too far right/down => clamp to maxCam=world-half=80
cx, cy = ClampCameraNoWrapViewport(
99*SCALE, 99*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 80*SCALE, cx)
require.Equal(t, 80*SCALE, cy)
// Inside range => unchanged
cx, cy = ClampCameraNoWrapViewport(
50*SCALE, 60*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
func TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter(t *testing.T) {
t.Parallel()
worldW := 50 * SCALE
worldH := 50 * SCALE
zoomFp := SCALE
// viewport 60px => span 60 units > world 50
viewportW, viewportH := 60, 60
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, worldW/2, cx)
require.Equal(t, worldH/2, cy)
}
func TestWorldClampRenderParamsNoWrap_UsesViewportClamp(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
p := RenderParams{
ViewportWidthPx: 40,
ViewportHeightPx: 40,
MarginXPx: 10,
MarginYPx: 10,
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.ClampRenderParamsNoWrap(&p)
// viewport half is 20, not 30 (margins ignored)
require.Equal(t, 20*SCALE, p.CameraXWorldFp)
require.Equal(t, 20*SCALE, p.CameraYWorldFp)
}
-50
View File
@@ -1,50 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_CircleRadiusScale_AffectsRenderedRadiusPx(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Ensure index state is initialized so Add triggers rebuild if needed.
w.IndexOnViewportChange(10, 10, 1.0)
_, err := w.AddCircle(5, 5, 2) // raw radius = 2 units
require.NoError(t, err)
// scale = 2.0
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
// Reindex explicitly (safe).
w.Reindex()
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))
circles := d.CommandsByName("AddCircle")
require.NotEmpty(t, circles)
// AddCircle args: cx, cy, rPx
rPx := circles[0].Args[2]
require.Equal(t, float64(4), rPx, "raw radius=2 with scale=2 => eff radius=4 => rPx=4 at zoom=1")
}
-29
View File
@@ -1,29 +0,0 @@
package world
type PointClassID uint8
const (
PointClassDefault PointClassID = iota
PointClassTrackUnknown
PointClassTrackIncoming
PointClassTrackOutgoing
)
type LineClassID uint8
const (
LineClassDefault LineClassID = iota
LineClassTrackIncoming
LineCLassTrackOutgoing
LineClassMeasurement
)
type CircleClassID uint8
const (
CircleClassDefault CircleClassID = iota
CircleClassHome
CircleClassAcquired
CircleClassOccupied
CircleClassFree
)
+261 -2
View File
@@ -1,10 +1,11 @@
package world
import (
"github.com/fogleman/gg"
"image"
"image/color"
"github.com/fogleman/gg"
"image/draw"
"reflect"
)
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
@@ -352,6 +353,7 @@ func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
}
}
// rgba8 converts any color.Color into 8-bit RGBA components.
func rgba8(c color.Color) (R, G, B, A byte) {
r, g, b, a := c.RGBA()
return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)
@@ -381,3 +383,260 @@ func (g *GGDrawer) DrawImageScaled(img image.Image, x, y, w, h int) {
g.DC.DrawImage(img, 0, 0)
g.DC.Pop()
}
// bgTileCacheKey identifies one scaled background-tile variant cached by GGDrawer.
type bgTileCacheKey struct {
imgPtr uintptr
scaleMode BackgroundScaleMode
canvasW int
canvasH int
srcW int
srcH int
}
// bgTileCache stores the most recently used scaled background tile.
type bgTileCache struct {
key bgTileCacheKey
valid bool
scaledTile *image.RGBA
tileW int
tileH int
}
// drawBackgroundFast renders the background directly into the RGBA backing
// image, bypassing gg path construction when the drawer supports it.
func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool {
th := w.Theme()
bgImg := th.BackgroundImage()
if bgImg == nil {
return false
}
dst, ok := g.DC.Image().(*image.RGBA)
if !ok || dst == nil {
return false
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
// Clamp rect to canvas.
if rect.W <= 0 || rect.H <= 0 {
return true
}
if rect.X < 0 {
rect.W += rect.X
rect.X = 0
}
if rect.Y < 0 {
rect.H += rect.Y
rect.Y = 0
}
if rect.X+rect.W > canvasW {
rect.W = canvasW - rect.X
}
if rect.Y+rect.H > canvasH {
rect.H = canvasH - rect.Y
}
if rect.W <= 0 || rect.H <= 0 {
return true
}
imgB := bgImg.Bounds()
srcW := imgB.Dx()
srcH := imgB.Dy()
if srcW <= 0 || srcH <= 0 {
return true
}
tileMode := th.BackgroundTileMode()
anchor := th.BackgroundAnchorMode()
scaleMode := th.BackgroundScaleMode()
// Compute scaled tile size in pixels (scale depends on canvas size).
tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode)
if tileW <= 0 || tileH <= 0 {
return true
}
// Prepare the tile image (possibly scaled) from cache.
tile := bgImg
if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH {
rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH)
if rgbaTile == nil {
// Fallback to slow path if we cannot scale (non-RGBA weirdness).
return false
}
tile = rgbaTile
}
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
switch tileMode {
case BackgroundTileNone:
// Draw single image centered in full canvas, then clipped by rect.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
case BackgroundTileRepeat:
originX := offX
originY := offY
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
for xx := startX; xx < rect.X+rect.W; xx += tileW {
w.drawOneTileRGBA(dst, tile, rect, xx, yy)
}
}
default:
// Treat unknown as none.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
}
return true
}
// getOrBuildScaledTile returns the cached scaled tile image for the current
// background configuration, rebuilding it when the cache key changes.
func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA {
// Identify image pointer (themes typically provide *image.RGBA).
ptr := imagePointer(img)
key := bgTileCacheKey{
imgPtr: ptr,
scaleMode: mode,
canvasW: canvasW,
canvasH: canvasH,
srcW: srcW,
srcH: srcH,
}
if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil &&
g.bgCache.tileW == dstW && g.bgCache.tileH == dstH {
return g.bgCache.scaledTile
}
// Scale only from *image.RGBA fast; otherwise, try a generic slow path.
var scaled *image.RGBA
if srcRGBA, ok := img.(*image.RGBA); ok {
scaled = scaleNearestRGBA(srcRGBA, dstW, dstH)
} else {
scaled = scaleNearestGeneric(img, dstW, dstH)
}
g.bgCache.key = key
g.bgCache.valid = true
g.bgCache.scaledTile = scaled
g.bgCache.tileW = dstW
g.bgCache.tileH = dstH
return scaled
}
// imagePointer returns a stable pointer identity for pointer-backed images.
// Non-pointer image values return 0, which disables cache reuse but remains correct.
func imagePointer(img image.Image) uintptr {
// Works well when img is a pointer type (e.g. *image.RGBA).
// If not pointer, Pointer() returns 0; cache will be less effective but still correct.
v := reflect.ValueOf(img)
if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer {
return v.Pointer()
}
return 0
}
// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling.
// This is intended for background textures; performance > quality.
func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := (y * sh) / dstH
srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4
dstOff := y * dst.Stride
for x := 0; x < dstW; x++ {
sx := (x * sw) / dstW
si := srcOff + sx*4
di := dstOff + x*4
dst.Pix[di+0] = src.Pix[si+0]
dst.Pix[di+1] = src.Pix[si+1]
dst.Pix[di+2] = src.Pix[si+2]
dst.Pix[di+3] = src.Pix[si+3]
}
}
return dst
}
// scaleNearestGeneric scales an arbitrary image.Image with nearest-neighbor sampling.
func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := sb.Min.Y + (y*sh)/dstH
for x := 0; x < dstW; x++ {
sx := sb.Min.X + (x*sw)/dstW
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect.
// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color.
func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) {
tileB := tile.Bounds()
tw := tileB.Dx()
th := tileB.Dy()
if tw <= 0 || th <= 0 {
return
}
// Intersection of tile rect and target rect.
tx0 := x
ty0 := y
tx1 := x + tw
ty1 := y + th
rx0 := rect.X
ry0 := rect.Y
rx1 := rect.X + rect.W
ry1 := rect.Y + rect.H
ix0 := max(tx0, rx0)
iy0 := max(ty0, ry0)
ix1 := min(tx1, rx1)
iy1 := min(ty1, ry1)
if ix0 >= ix1 || iy0 >= iy1 {
return
}
dstR := image.Rect(ix0, iy0, ix1, iy1)
srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)}
draw.Draw(dst, dstR, tile, srcPt, draw.Over)
}
-40
View File
@@ -1,40 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
)
func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) {
t.Parallel()
dc := gg.NewContext(40, 20)
d := &GGDrawer{DC: dc}
// Fill background to white.
d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Configure stroke to red and draw first line.
d.SetStrokeColor(color.RGBA{R: 255, A: 255})
d.SetLineWidth(2)
d.AddLine(2, 5, 38, 5)
d.Stroke()
// Clear a rect in the middle with gray (must not affect stroke state).
d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255})
// Draw second line WITHOUT reapplying stroke style; it must still be red.
d.AddLine(2, 15, 38, 15)
d.Stroke()
img := dc.Image()
// Sample a pixel from the second line (y ~15). We expect red channel dominates.
r, g, b, a := img.At(20, 15).RGBA()
require.Greater(t, a, uint32(0), "pixel must not be fully transparent")
require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo")
require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo")
}
+387 -4
View File
@@ -1,12 +1,13 @@
package world
import (
"image"
"image/color"
"testing"
"fmt"
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
"image"
"image/color"
"sync"
"testing"
)
func hasAnyNonTransparentPixel(img image.Image) bool {
@@ -27,6 +28,7 @@ func pixelHasAlpha(img image.Image, x, y int) bool {
return a != 0
}
// TestGGDrawerStrokeSequenceProducesPixels verifies gG Drawer Stroke Sequence Produces Pixels.
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
t.Parallel()
@@ -43,6 +45,7 @@ func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
}
// TestGGDrawerFillSequenceProducesPixels verifies gG Drawer Fill Sequence Produces Pixels.
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
t.Parallel()
@@ -56,6 +59,7 @@ func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
// TestGGDrawerPointSequenceProducesPixels verifies gG Drawer Point Sequence Produces Pixels.
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
t.Parallel()
@@ -69,6 +73,7 @@ func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
// TestGGDrawerClipRectLimitsDrawing verifies gG Drawer Clip Rect Limits Drawing.
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
t.Parallel()
@@ -88,6 +93,7 @@ func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
require.False(t, pixelHasAlpha(img, 15, 16))
}
// TestGGDrawerResetClipClearsClip verifies gG Drawer Reset Clip Clears Clip.
func TestGGDrawerResetClipClearsClip(t *testing.T) {
t.Parallel()
@@ -103,6 +109,7 @@ func TestGGDrawerResetClipClearsClip(t *testing.T) {
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
// TestGGDrawerClearRectTo_FillsBackground verifies gG Drawer Clear Rect To Fills Background.
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
t.Parallel()
@@ -130,6 +137,7 @@ func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
require.NotEqual(t, uint32(0), a2)
}
// TestGGDrawerSaveRestoreRestoresClipState verifies gG Drawer Save Restore Restores Clip State.
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
t.Parallel()
@@ -147,6 +155,7 @@ func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
// TestGGDrawerNestedSaveRestoreRestoresOuterClip verifies gG Drawer Nested Save Restore Restores Outer Clip.
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
t.Parallel()
@@ -169,6 +178,7 @@ func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
require.False(t, pixelHasAlpha(img, 25, 16))
}
// TestFakePrimitiveDrawerRecordsCommandsAndState verifies fake Primitive Drawer Records Commands And State.
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
t.Parallel()
@@ -208,6 +218,7 @@ func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor)
}
// TestFakePrimitiveDrawerRestoreWithoutSavePanics verifies fake Primitive Drawer Restore Without Save Panics.
func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
t.Parallel()
@@ -218,6 +229,7 @@ func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
})
}
// TestFakePrimitiveDrawerSaveRestoreRestoresState verifies fake Primitive Drawer Save Restore Restores State.
func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
t.Parallel()
@@ -236,6 +248,7 @@ func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
require.Equal(t, 0, d.SaveDepth())
}
// TestFakePrimitiveDrawerResetClipClearsOnlyClipState verifies fake Primitive Drawer Reset Clip Clears Only Clip State.
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
t.Parallel()
@@ -251,6 +264,7 @@ func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
require.Empty(t, state.Clips)
}
// TestGGDrawerCopyShift_ShiftsPixels verifies gG Drawer Copy Shift Shifts Pixels.
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
t.Parallel()
@@ -276,3 +290,372 @@ func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
_, _, _, a2 := img.At(0, 0).RGBA()
require.Equal(t, uint32(0), a2)
}
// TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState verifies gG Drawer Clear Rect To Does Not Affect Stroke State.
func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) {
t.Parallel()
dc := gg.NewContext(40, 20)
d := &GGDrawer{DC: dc}
// Fill background to white.
d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Configure stroke to red and draw first line.
d.SetStrokeColor(color.RGBA{R: 255, A: 255})
d.SetLineWidth(2)
d.AddLine(2, 5, 38, 5)
d.Stroke()
// Clear a rect in the middle with gray (must not affect stroke state).
d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255})
// Draw second line WITHOUT reapplying stroke style; it must still be red.
d.AddLine(2, 15, 38, 15)
d.Stroke()
img := dc.Image()
// Sample a pixel from the second line (y ~15). We expect red channel dominates.
r, g, b, a := img.At(20, 15).RGBA()
require.Greater(t, a, uint32(0), "pixel must not be fully transparent")
require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo")
require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo")
}
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
type fakeClipRect struct {
X, Y float64
W, H float64
}
// fakeDrawerState stores the active fake drawing state.
// The state is copied on Save and restored on Restore.
type fakeDrawerState struct {
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// clone returns a deep copy of the state.
func (s fakeDrawerState) clone() fakeDrawerState {
out := s
out.Dashes = append([]float64(nil), s.Dashes...)
out.Clips = append([]fakeClipRect(nil), s.Clips...)
return out
}
// fakeDrawerCommand is one recorded drawer call together with a snapshot
// of the active fake drawing state at the moment of the call.
type fakeDrawerCommand struct {
Name string
Args []float64
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// String returns a compact debug representation useful in assertion failures.
func (c fakeDrawerCommand) String() string {
return fmt.Sprintf(
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
c.Name,
c.Args,
c.StrokeColor,
c.FillColor,
c.LineWidth,
c.Dashes,
c.DashOffset,
c.Clips,
)
}
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
// It records all calls and emulates stateful behavior, including nested
// Save/Restore and clip reset semantics.
type fakePrimitiveDrawer struct {
commands []fakeDrawerCommand
state fakeDrawerState
stack []fakeDrawerState
mu sync.Mutex
}
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
// rgbaColor converts any color.Color into a comparable RGBA value.
func rgbaColor(c color.Color) color.RGBA {
if c == nil {
return color.RGBA{}
}
return color.RGBAModel.Convert(c).(color.RGBA)
}
// snapshotCommand records one command together with the current state snapshot.
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
cmd := fakeDrawerCommand{
Name: name,
Args: append([]float64(nil), args...),
StrokeColor: d.state.StrokeColor,
FillColor: d.state.FillColor,
LineWidth: d.state.LineWidth,
Dashes: append([]float64(nil), d.state.Dashes...),
DashOffset: d.state.DashOffset,
Clips: append([]fakeClipRect(nil), d.state.Clips...),
}
d.commands = append(d.commands, cmd)
}
// Save stores the current fake state.
func (d *fakePrimitiveDrawer) Save() {
d.stack = append(d.stack, d.state.clone())
d.snapshotCommand("Save")
}
// Restore restores the most recently saved fake state.
func (d *fakePrimitiveDrawer) Restore() {
if len(d.stack) == 0 {
panic("fakePrimitiveDrawer: Restore without matching Save")
}
d.state = d.stack[len(d.stack)-1]
d.stack = d.stack[:len(d.stack)-1]
d.snapshotCommand("Restore")
}
// ResetClip clears the current fake clip stack.
func (d *fakePrimitiveDrawer) ResetClip() {
d.state.Clips = nil
d.snapshotCommand("ResetClip")
}
// ClipRect appends one clip rectangle to the current fake state.
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
d.snapshotCommand("ClipRect", x, y, w, h)
}
// SetStrokeColor sets the current fake stroke color.
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
d.state.StrokeColor = rgbaColor(c)
d.snapshotCommand("SetStrokeColor")
}
// SetFillColor sets the current fake fill color.
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
d.state.FillColor = rgbaColor(c)
d.snapshotCommand("SetFillColor")
}
// SetLineWidth sets the current fake line width.
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
d.state.LineWidth = width
d.snapshotCommand("SetLineWidth", width)
}
// SetDash sets the current fake dash pattern.
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
d.state.Dashes = append([]float64(nil), dashes...)
d.snapshotCommand("SetDash", dashes...)
}
// SetDashOffset sets the current fake dash offset.
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
d.state.DashOffset = offset
d.snapshotCommand("SetDashOffset", offset)
}
// AddPoint records a point path append command.
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
d.snapshotCommand("AddPoint", x, y, r)
}
// AddLine records a line path append command.
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
d.snapshotCommand("AddLine", x1, y1, x2, y2)
}
// AddCircle records a circle path append command.
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
d.snapshotCommand("AddCircle", cx, cy, r)
}
// Stroke records a stroke finalization command.
func (d *fakePrimitiveDrawer) Stroke() {
d.snapshotCommand("Stroke")
}
// Fill records a fill finalization command.
func (d *fakePrimitiveDrawer) Fill() {
d.snapshotCommand("Fill")
}
// Commands returns a defensive copy of the recorded command log.
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
out := make([]fakeDrawerCommand, len(d.commands))
copy(out, d.commands)
return out
}
// CommandNames returns only command names in call order.
func (d *fakePrimitiveDrawer) CommandNames() []string {
out := make([]string, 0, len(d.commands))
for _, cmd := range d.commands {
out = append(out, cmd.Name)
}
return out
}
// CommandsByName returns all commands with the given name.
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
var out []fakeDrawerCommand
for _, cmd := range d.commands {
if cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
// LastCommand returns the last recorded command and whether it exists.
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
if len(d.commands) == 0 {
return fakeDrawerCommand{}, false
}
return d.commands[len(d.commands)-1], true
}
// CurrentState returns a defensive copy of the current fake state.
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
return d.state.clone()
}
// SaveDepth returns the current Save/Restore nesting depth.
func (d *fakePrimitiveDrawer) SaveDepth() int {
return len(d.stack)
}
// ResetLog clears only the command log and keeps the current state intact.
func (d *fakePrimitiveDrawer) ResetLog() {
d.commands = nil
}
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
}
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
// Store as a command; tests usually only care that it was called.
d.snapshotCommand("ClearAllTo")
}
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) {
d.snapshotCommand("DrawImage", float64(x), float64(y))
}
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.commands = d.commands[:0]
}
// requireDrawerCommandNames asserts the exact command sequence recorded
// by fakePrimitiveDrawer.
func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) {
t.Helper()
require.Equal(t, want, d.CommandNames())
}
// requireDrawerCommandCount asserts the number of recorded commands.
func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) {
t.Helper()
require.Len(t, d.Commands(), want)
}
// requireDrawerCommandAt returns the command at the specified index.
func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmds := d.Commands()
require.GreaterOrEqual(t, index, 0)
require.Less(t, index, len(cmds))
return cmds[index]
}
// requireDrawerSingleCommand returns the only command with the given name.
func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand {
t.Helper()
cmds := d.CommandsByName(name)
require.Len(t, cmds, 1)
return cmds[0]
}
// requireCommandName asserts the command name.
func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) {
t.Helper()
require.Equal(t, want, cmd.Name)
}
// requireCommandArgs asserts the exact float arguments.
func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Args)
}
// requireCommandArgsInDelta asserts the float arguments with tolerance.
func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) {
t.Helper()
require.Len(t, cmd.Args, len(want))
for i := range want {
require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i)
}
}
// requireCommandClipRects asserts the clip stack snapshot attached to the command.
func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) {
t.Helper()
require.Equal(t, want, cmd.Clips)
}
// requireCommandLineWidth asserts the line width snapshot attached to the command.
func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.LineWidth)
}
// requireCommandDashes asserts the dash snapshot attached to the command.
func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Dashes)
}
// requireCommandDashOffset asserts the dash offset snapshot attached to the command.
func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.DashOffset)
}
-95
View File
@@ -1,95 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// requireDrawerCommandNames asserts the exact command sequence recorded
// by fakePrimitiveDrawer.
func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) {
t.Helper()
require.Equal(t, want, d.CommandNames())
}
// requireDrawerCommandCount asserts the number of recorded commands.
func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) {
t.Helper()
require.Len(t, d.Commands(), want)
}
// requireDrawerCommandAt returns the command at the specified index.
func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmds := d.Commands()
require.GreaterOrEqual(t, index, 0)
require.Less(t, index, len(cmds))
return cmds[index]
}
// requireDrawerSingleCommand returns the only command with the given name.
func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand {
t.Helper()
cmds := d.CommandsByName(name)
require.Len(t, cmds, 1)
return cmds[0]
}
// requireCommandName asserts the command name.
func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) {
t.Helper()
require.Equal(t, want, cmd.Name)
}
// requireCommandArgs asserts the exact float arguments.
func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Args)
}
// requireCommandArgsInDelta asserts the float arguments with tolerance.
func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) {
t.Helper()
require.Len(t, cmd.Args, len(want))
for i := range want {
require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i)
}
}
// requireCommandClipRects asserts the clip stack snapshot attached to the command.
func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) {
t.Helper()
require.Equal(t, want, cmd.Clips)
}
// requireCommandLineWidth asserts the line width snapshot attached to the command.
func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.LineWidth)
}
// requireCommandDashes asserts the dash snapshot attached to the command.
func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Dashes)
}
// requireCommandDashOffset asserts the dash offset snapshot attached to the command.
func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.DashOffset)
}
-257
View File
@@ -1,257 +0,0 @@
package world
import (
"fmt"
"image"
"image/color"
"sync"
)
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
type fakeClipRect struct {
X, Y float64
W, H float64
}
// fakeDrawerState stores the active fake drawing state.
// The state is copied on Save and restored on Restore.
type fakeDrawerState struct {
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// clone returns a deep copy of the state.
func (s fakeDrawerState) clone() fakeDrawerState {
out := s
out.Dashes = append([]float64(nil), s.Dashes...)
out.Clips = append([]fakeClipRect(nil), s.Clips...)
return out
}
// fakeDrawerCommand is one recorded drawer call together with a snapshot
// of the active fake drawing state at the moment of the call.
type fakeDrawerCommand struct {
Name string
Args []float64
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// String returns a compact debug representation useful in assertion failures.
func (c fakeDrawerCommand) String() string {
return fmt.Sprintf(
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
c.Name,
c.Args,
c.StrokeColor,
c.FillColor,
c.LineWidth,
c.Dashes,
c.DashOffset,
c.Clips,
)
}
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
// It records all calls and emulates stateful behavior, including nested
// Save/Restore and clip reset semantics.
type fakePrimitiveDrawer struct {
commands []fakeDrawerCommand
state fakeDrawerState
stack []fakeDrawerState
mu sync.Mutex
}
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
// rgbaColor converts any color.Color into a comparable RGBA value.
func rgbaColor(c color.Color) color.RGBA {
if c == nil {
return color.RGBA{}
}
return color.RGBAModel.Convert(c).(color.RGBA)
}
// snapshotCommand records one command together with the current state snapshot.
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
cmd := fakeDrawerCommand{
Name: name,
Args: append([]float64(nil), args...),
StrokeColor: d.state.StrokeColor,
FillColor: d.state.FillColor,
LineWidth: d.state.LineWidth,
Dashes: append([]float64(nil), d.state.Dashes...),
DashOffset: d.state.DashOffset,
Clips: append([]fakeClipRect(nil), d.state.Clips...),
}
d.commands = append(d.commands, cmd)
}
// Save stores the current fake state.
func (d *fakePrimitiveDrawer) Save() {
d.stack = append(d.stack, d.state.clone())
d.snapshotCommand("Save")
}
// Restore restores the most recently saved fake state.
func (d *fakePrimitiveDrawer) Restore() {
if len(d.stack) == 0 {
panic("fakePrimitiveDrawer: Restore without matching Save")
}
d.state = d.stack[len(d.stack)-1]
d.stack = d.stack[:len(d.stack)-1]
d.snapshotCommand("Restore")
}
// ResetClip clears the current fake clip stack.
func (d *fakePrimitiveDrawer) ResetClip() {
d.state.Clips = nil
d.snapshotCommand("ResetClip")
}
// ClipRect appends one clip rectangle to the current fake state.
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
d.snapshotCommand("ClipRect", x, y, w, h)
}
// SetStrokeColor sets the current fake stroke color.
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
d.state.StrokeColor = rgbaColor(c)
d.snapshotCommand("SetStrokeColor")
}
// SetFillColor sets the current fake fill color.
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
d.state.FillColor = rgbaColor(c)
d.snapshotCommand("SetFillColor")
}
// SetLineWidth sets the current fake line width.
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
d.state.LineWidth = width
d.snapshotCommand("SetLineWidth", width)
}
// SetDash sets the current fake dash pattern.
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
d.state.Dashes = append([]float64(nil), dashes...)
d.snapshotCommand("SetDash", dashes...)
}
// SetDashOffset sets the current fake dash offset.
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
d.state.DashOffset = offset
d.snapshotCommand("SetDashOffset", offset)
}
// AddPoint records a point path append command.
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
d.snapshotCommand("AddPoint", x, y, r)
}
// AddLine records a line path append command.
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
d.snapshotCommand("AddLine", x1, y1, x2, y2)
}
// AddCircle records a circle path append command.
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
d.snapshotCommand("AddCircle", cx, cy, r)
}
// Stroke records a stroke finalization command.
func (d *fakePrimitiveDrawer) Stroke() {
d.snapshotCommand("Stroke")
}
// Fill records a fill finalization command.
func (d *fakePrimitiveDrawer) Fill() {
d.snapshotCommand("Fill")
}
// Commands returns a defensive copy of the recorded command log.
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
out := make([]fakeDrawerCommand, len(d.commands))
copy(out, d.commands)
return out
}
// CommandNames returns only command names in call order.
func (d *fakePrimitiveDrawer) CommandNames() []string {
out := make([]string, 0, len(d.commands))
for _, cmd := range d.commands {
out = append(out, cmd.Name)
}
return out
}
// CommandsByName returns all commands with the given name.
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
var out []fakeDrawerCommand
for _, cmd := range d.commands {
if cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
// LastCommand returns the last recorded command and whether it exists.
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
if len(d.commands) == 0 {
return fakeDrawerCommand{}, false
}
return d.commands[len(d.commands)-1], true
}
// CurrentState returns a defensive copy of the current fake state.
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
return d.state.clone()
}
// SaveDepth returns the current Save/Restore nesting depth.
func (d *fakePrimitiveDrawer) SaveDepth() int {
return len(d.stack)
}
// ResetLog clears only the command log and keeps the current state intact.
func (d *fakePrimitiveDrawer) ResetLog() {
d.commands = nil
}
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
}
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
// Store as a command; tests usually only care that it was called.
d.snapshotCommand("ClearAllTo")
}
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) {
d.snapshotCommand("DrawImage", float64(x), float64(y))
}
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.commands = d.commands[:0]
}
@@ -1,149 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/fogleman/gg"
)
type benchBgTheme struct {
img image.Image
anchor BackgroundAnchorMode
tileMode BackgroundTileMode
scaleMode BackgroundScaleMode
}
func (t benchBgTheme) ID() string { return "benchbg" }
func (t benchBgTheme) Name() string { return "benchbg" }
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
func (t benchBgTheme) PointStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t benchBgTheme) LineStyle() Style {
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
// Some primitives to keep it realistic but not dominant.
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
// Initial render (commit state).
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
}
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
}
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
}
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
// Background tile (RGBA) — typical texture size.
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
// Make it semi-transparent so draw.Over has real work.
for y := 0; y < 96; y++ {
for x := 0; x < 96; x++ {
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
}
}
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
-255
View File
@@ -1,255 +0,0 @@
package world
import (
"image"
"image/draw"
"reflect"
)
type bgTileCacheKey struct {
imgPtr uintptr
scaleMode BackgroundScaleMode
canvasW int
canvasH int
srcW int
srcH int
}
type bgTileCache struct {
key bgTileCacheKey
valid bool
scaledTile *image.RGBA
tileW int
tileH int
}
func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool {
th := w.Theme()
bgImg := th.BackgroundImage()
if bgImg == nil {
return false
}
dst, ok := g.DC.Image().(*image.RGBA)
if !ok || dst == nil {
return false
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
// Clamp rect to canvas.
if rect.W <= 0 || rect.H <= 0 {
return true
}
if rect.X < 0 {
rect.W += rect.X
rect.X = 0
}
if rect.Y < 0 {
rect.H += rect.Y
rect.Y = 0
}
if rect.X+rect.W > canvasW {
rect.W = canvasW - rect.X
}
if rect.Y+rect.H > canvasH {
rect.H = canvasH - rect.Y
}
if rect.W <= 0 || rect.H <= 0 {
return true
}
imgB := bgImg.Bounds()
srcW := imgB.Dx()
srcH := imgB.Dy()
if srcW <= 0 || srcH <= 0 {
return true
}
tileMode := th.BackgroundTileMode()
anchor := th.BackgroundAnchorMode()
scaleMode := th.BackgroundScaleMode()
// Compute scaled tile size in pixels (scale depends on canvas size).
tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode)
if tileW <= 0 || tileH <= 0 {
return true
}
// Prepare the tile image (possibly scaled) from cache.
tile := bgImg
if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH {
rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH)
if rgbaTile == nil {
// Fallback to slow path if we cannot scale (non-RGBA weirdness).
return false
}
tile = rgbaTile
}
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
switch tileMode {
case BackgroundTileNone:
// Draw single image centered in full canvas, then clipped by rect.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
case BackgroundTileRepeat:
originX := offX
originY := offY
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
for xx := startX; xx < rect.X+rect.W; xx += tileW {
w.drawOneTileRGBA(dst, tile, rect, xx, yy)
}
}
default:
// Treat unknown as none.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
}
return true
}
func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA {
// Identify image pointer (themes typically provide *image.RGBA).
ptr := imagePointer(img)
key := bgTileCacheKey{
imgPtr: ptr,
scaleMode: mode,
canvasW: canvasW,
canvasH: canvasH,
srcW: srcW,
srcH: srcH,
}
if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil &&
g.bgCache.tileW == dstW && g.bgCache.tileH == dstH {
return g.bgCache.scaledTile
}
// Scale only from *image.RGBA fast; otherwise, try a generic slow path.
var scaled *image.RGBA
if srcRGBA, ok := img.(*image.RGBA); ok {
scaled = scaleNearestRGBA(srcRGBA, dstW, dstH)
} else {
scaled = scaleNearestGeneric(img, dstW, dstH)
}
g.bgCache.key = key
g.bgCache.valid = true
g.bgCache.scaledTile = scaled
g.bgCache.tileW = dstW
g.bgCache.tileH = dstH
return scaled
}
func imagePointer(img image.Image) uintptr {
// Works well when img is a pointer type (e.g. *image.RGBA).
// If not pointer, Pointer() returns 0; cache will be less effective but still correct.
v := reflect.ValueOf(img)
if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer {
return v.Pointer()
}
return 0
}
// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling.
// This is intended for background textures; performance > quality.
func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := (y * sh) / dstH
srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4
dstOff := y * dst.Stride
for x := 0; x < dstW; x++ {
sx := (x * sw) / dstW
si := srcOff + sx*4
di := dstOff + x*4
dst.Pix[di+0] = src.Pix[si+0]
dst.Pix[di+1] = src.Pix[si+1]
dst.Pix[di+2] = src.Pix[si+2]
dst.Pix[di+3] = src.Pix[si+3]
}
}
return dst
}
func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := sb.Min.Y + (y*sh)/dstH
for x := 0; x < dstW; x++ {
sx := sb.Min.X + (x*sw)/dstW
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect.
// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color.
func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) {
tileB := tile.Bounds()
tw := tileB.Dx()
th := tileB.Dy()
if tw <= 0 || th <= 0 {
return
}
// Intersection of tile rect and target rect.
tx0 := x
ty0 := y
tx1 := x + tw
ty1 := y + th
rx0 := rect.X
ry0 := rect.Y
rx1 := rect.X + rect.W
ry1 := rect.Y + rect.H
ix0 := max(tx0, rx0)
iy0 := max(ty0, ry0)
ix1 := min(tx1, rx1)
iy1 := min(ty1, ry1)
if ix0 >= ix1 || iy0 >= iy1 {
return
}
dstR := image.Rect(ix0, iy0, ix1, iy1)
srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)}
draw.Draw(dst, dstR, tile, srcPt, draw.Over)
}
-151
View File
@@ -1,151 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
t.Parallel()
type tc struct {
name string
fillVisible bool
rawRadius int // world units (not fixed); zoom=1 => 1px per unit
scaleFp int
hitSlopPx int
cursorDxPx int // offset from center in pixels along X axis
wantHit bool
wantKind PrimitiveKind
}
// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
params := RenderParams{
ViewportWidthPx: 200,
ViewportHeightPx: 200,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 10 * SCALE,
CameraYWorldFp: 10 * SCALE,
CameraZoom: 1.0,
}
tests := []tc{
{
name: "filled: on boundary hits (R=4, S=1, dx=4)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "filled: outside beyond slop misses (R=4, S=1, dx=6)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
{
name: "filled: just inside slop hits (R=4, S=1, dx=5)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 5, // == R+S
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: center must miss even if slop would cover",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 10, // huge, would normally include center without our rule
cursorDxPx: 0,
wantHit: false,
},
{
name: "stroke-only: on ring hits (R=4, S=1, dx=4)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 2, // 2 < R-S = 3
wantHit: false,
},
{
name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := NewWorld(20, 20)
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))
// Build a stroke-only circle style if needed.
var opts []CircleOpt
opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))
if !tt.fillVisible {
// Force fill alpha=0 => stroke-only for hit-test and rendering.
sw := 1.0
styleID := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: &sw,
})
opts = append(opts, CircleWithStyleID(styleID))
}
_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
require.NoError(t, err)
w.Reindex()
// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
cx := params.ViewportWidthPx/2 + tt.cursorDxPx
cy := params.ViewportHeightPx / 2
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, cx, cy)
require.NoError(t, err)
if !tt.wantHit {
require.Empty(t, hits)
return
}
require.NotEmpty(t, hits)
require.Equal(t, tt.wantKind, hits[0].Kind)
})
}
}
-74
View File
@@ -1,74 +0,0 @@
package world
import "math/bits"
// u128 is an unsigned 128-bit integer for safe squared comparisons.
type u128 struct{ hi, lo uint64 }
func u128FromMul64(a, b uint64) u128 {
hi, lo := bits.Mul64(a, b)
return u128{hi: hi, lo: lo}
}
func u128Add(a, b u128) u128 {
lo := a.lo + b.lo
hi := a.hi + b.hi
if lo < a.lo {
hi++
}
return u128{hi: hi, lo: lo}
}
func u128Cmp(a, b u128) int {
if a.hi < b.hi {
return -1
}
if a.hi > b.hi {
return 1
}
if a.lo < b.lo {
return -1
}
if a.lo > b.lo {
return 1
}
return 0
}
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}
func sqU128Int64(x int64) u128 {
u := uint64(abs64(x))
return u128FromMul64(u, u)
}
func distSqU128(dx, dy int64) u128 {
return u128Add(sqU128Int64(dx), sqU128Int64(dy))
}
// shortestTorusDelta returns the shortest signed delta from a->b on a torus axis of size.
// It is deterministic in tie cases (size even, exactly half): chooses negative direction.
func shortestTorusDelta(a, b, size int) int64 {
d := int64(b - a)
s := int64(size)
half := s / 2
// Normalize d into (-s, s).
d = d % s
if d <= -half {
d += s
} else if d > half {
d -= s
}
// Tie case when size even and d == +half: choose -half.
if s%2 == 0 && d == half {
d = -half
}
return d
}
-247
View File
@@ -1,247 +0,0 @@
package world
import (
"image/color"
"math/bits"
)
func effectiveHitSlopPx(hitSlopPx int, def int) int {
if hitSlopPx > 0 {
return hitSlopPx
}
return def
}
func alphaNonZero(c color.Color) bool {
if c == nil {
return false
}
_, _, _, a := c.RGBA()
return a != 0
}
func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(p.X, cx, worldW)
dy = shortestTorusDelta(p.Y, cy, worldH)
} else {
dx = int64(cx - p.X)
dy = int64(cy - p.Y)
}
// Point is treated as a small disc: dist <= slop.
ds := distSqU128(dx, dy)
rs := sqU128Int64(int64(slopW))
if u128Cmp(ds, rs) <= 0 {
return Hit{
ID: p.Id,
Kind: KindPoint,
Priority: p.Priority,
StyleID: p.StyleID,
DistanceSq: ds,
X: p.X,
Y: p.Y,
}, true
}
return Hit{}, false
}
func hitCircle(c Circle, effRadiusFp int, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
fillVisible := alphaNonZero(style.FillColor)
// Determine if circle is point-like at current zoom.
// IMPORTANT: point-like disc behavior applies only for filled circles.
rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp)
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(c.X, cx, worldW)
dy = shortestTorusDelta(c.Y, cy, worldH)
} else {
dx = int64(cx - c.X)
dy = int64(cy - c.Y)
}
ds := distSqU128(dx, dy)
// Filled + point-like: treat as a disc with minimum visible radius + slop.
if pointLike {
// Treat as a disc with minimum visible radius in px.
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
effR := minRW
if effRadiusFp > effR {
effR = effRadiusFp
}
r := effR + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
// Filled circle: hit-test by disc (surface).
if fillVisible {
r := effRadiusFp + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
// Stroke-only circle: ring hit, but NEVER at exact center.
// For very small circles, expand the effective radius to a minimum visible size
// so that ring selection remains practical, while still excluding center.
effR := effRadiusFp
if rPx < CirclePointLikeMinRadiusPx {
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
if minRW > effR {
effR = minRW
}
}
low := effR - slopW
// IMPORTANT: center must not hit for stroke-only circles.
if low < 1 {
low = 1
}
high := effR + slopW
lowSq := sqU128Int64(int64(low))
highSq := sqU128Int64(int64(high))
if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
// For wrap: compare against torus-shortest representation (same as rendering).
// We test all segments produced by torusShortestLineSegments and take the best (min distance).
segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
if allowWrap {
segs = torusShortestLineSegments(l, worldW, worldH)
}
best := Hit{}
found := false
for _, s := range segs {
ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2))
// Check ds <= slopW^2
if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 {
h := Hit{
ID: l.Id,
Kind: KindLine,
Priority: l.Priority,
StyleID: l.StyleID,
DistanceSq: ds,
X1: l.X1,
Y1: l.Y1,
X2: l.X2,
Y2: l.Y2,
}
if !found || hitLess(h, best) {
best = h
found = true
}
}
}
return best, found
}
// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons.
func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 {
abx := bx - ax
aby := by - ay
apx := px - ax
apy := py - ay
// Degenerate segment => distance to point A.
if abx == 0 && aby == 0 {
return distSqU128(apx, apy)
}
dot := apx*abx + apy*aby
if dot <= 0 {
return distSqU128(apx, apy)
}
abLen2 := abx*abx + aby*aby
if dot >= abLen2 {
bpx := px - bx
bpy := py - by
return distSqU128(bpx, bpy)
}
// Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers.
// Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain
// would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use
// dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64.
//
// cross = AP x AB
cross := apx*aby - apy*abx
// cross^2 fits in u128, abLen2 fits in int64.
c2 := sqU128Int64(cross)
return u128DivByU64(c2, uint64(abLen2))
}
// u128DivByU64 returns floor(a / d) where d>0, producing u128 result.
// Here we only need it for tie-breaking (monotonic).
func u128DivByU64(a u128, d uint64) u128 {
if d == 0 {
panic("u128DivByU64: divide by zero")
}
// Simple long division for 128/64 -> 128 quotient (but high part will be small here).
// We compute using two-step: divide high then combine.
qHi := a.hi / d
rHi := a.hi % d
// Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d.
// Use bits.Div64 for (hi, lo)/d.
qLo, _ := bits.Div64(rHi, a.lo, d)
return u128{hi: qHi, lo: qLo}
}
+150 -2
View File
@@ -1,12 +1,12 @@
package world
import (
"github.com/stretchr/testify/require"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
t.Parallel()
@@ -48,6 +48,7 @@ func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
require.Equal(t, idLine, hits[2].ID)
}
// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits.
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
t.Parallel()
@@ -77,6 +78,7 @@ func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
require.Equal(t, idCircle, hits[0].ID)
}
// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits.
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
t.Parallel()
@@ -105,6 +107,7 @@ func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
require.NotEmpty(t, hits)
}
// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center.
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
t.Parallel()
@@ -151,6 +154,7 @@ func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
require.Equal(t, KindCircle, hits[0].Kind)
}
// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area.
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
t.Parallel()
@@ -186,3 +190,147 @@ func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
// Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options.
// We'll add a small-slope circle and test deterministically.
}
// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table.
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
t.Parallel()
type tc struct {
name string
fillVisible bool
rawRadius int // world units (not fixed); zoom=1 => 1px per unit
scaleFp int
hitSlopPx int
cursorDxPx int // offset from center in pixels along X axis
wantHit bool
wantKind PrimitiveKind
}
// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
params := RenderParams{
ViewportWidthPx: 200,
ViewportHeightPx: 200,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 10 * SCALE,
CameraYWorldFp: 10 * SCALE,
CameraZoom: 1.0,
}
tests := []tc{
{
name: "filled: on boundary hits (R=4, S=1, dx=4)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "filled: outside beyond slop misses (R=4, S=1, dx=6)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
{
name: "filled: just inside slop hits (R=4, S=1, dx=5)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 5, // == R+S
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: center must miss even if slop would cover",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 10, // huge, would normally include center without our rule
cursorDxPx: 0,
wantHit: false,
},
{
name: "stroke-only: on ring hits (R=4, S=1, dx=4)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 2, // 2 < R-S = 3
wantHit: false,
},
{
name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := NewWorld(20, 20)
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))
// Build a stroke-only circle style if needed.
var opts []CircleOpt
opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))
if !tt.fillVisible {
// Force fill alpha=0 => stroke-only for hit-test and rendering.
sw := 1.0
styleID := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: &sw,
})
opts = append(opts, CircleWithStyleID(styleID))
}
_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
require.NoError(t, err)
w.Reindex()
// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
cx := params.ViewportWidthPx/2 + tt.cursorDxPx
cy := params.ViewportHeightPx / 2
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, cx, cy)
require.NoError(t, err)
if !tt.wantHit {
require.Empty(t, hits)
return
}
require.NotEmpty(t, hits)
require.Equal(t, tt.wantKind, hits[0].Kind)
})
}
}
File diff suppressed because it is too large Load Diff
-155
View File
@@ -1,155 +0,0 @@
package world
// Functional options for primitive creation.
// Defaults are applied first, then user options override.
type PointOpt func(*PointOptions)
type PointOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class PointClassID
HitSlopPx int
hasStyleID bool
}
func defaultPointOptions() PointOptions {
return PointOptions{
Priority: DefaultPriorityPoint,
StyleID: StyleIDDefaultPoint,
Class: PointClassDefault,
}
}
func PointWithPriority(p int) PointOpt {
return func(o *PointOptions) {
o.Priority = p
}
}
// PointWithStyleID forces the point to use a pre-registered style.
func PointWithStyleID(id StyleID) PointOpt {
return func(o *PointOptions) {
o.StyleID = id
o.hasStyleID = true
// Explicit style ID wins over overrides.
o.Override = StyleOverride{}
}
}
func PointWithClass(c PointClassID) PointOpt {
return func(o *PointOptions) { o.Class = c }
}
// PointWithStyleOverride derives a style from default point style and applies overrides.
// If you also set StyleID, StyleID wins.
func PointWithStyleOverride(ov StyleOverride) PointOpt {
return func(o *PointOptions) {
o.Override = ov
}
}
func PointWithHitSlopPx(px int) PointOpt {
return func(o *PointOptions) { o.HitSlopPx = px }
}
type CircleOpt func(*CircleOptions)
type CircleOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class CircleClassID
HitSlopPx int
hasStyleID bool
}
func defaultCircleOptions() CircleOptions {
return CircleOptions{
Priority: DefaultPriorityCircle,
StyleID: StyleIDDefaultCircle,
Class: CircleClassDefault,
}
}
func CircleWithPriority(p int) CircleOpt {
return func(o *CircleOptions) {
o.Priority = p
}
}
func CircleWithStyleID(id StyleID) CircleOpt {
return func(o *CircleOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
func CircleWithClass(c CircleClassID) CircleOpt {
return func(o *CircleOptions) { o.Class = c }
}
func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
return func(o *CircleOptions) {
o.Override = ov
}
}
func CircleWithHitSlopPx(px int) CircleOpt {
return func(o *CircleOptions) { o.HitSlopPx = px }
}
type LineOpt func(*LineOptions)
type LineOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class LineClassID
HitSlopPx int
hasStyleID bool
}
func defaultLineOptions() LineOptions {
return LineOptions{
Priority: DefaultPriorityLine,
StyleID: StyleIDDefaultLine,
Class: LineClassDefault,
}
}
func LineWithPriority(p int) LineOpt {
return func(o *LineOptions) {
o.Priority = p
}
}
func LineWithStyleID(id StyleID) LineOpt {
return func(o *LineOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
func LineWithClass(c LineClassID) LineOpt {
return func(o *LineOptions) { o.Class = c }
}
func LineWithStyleOverride(ov StyleOverride) LineOpt {
return func(o *LineOptions) {
o.Override = ov
}
}
func LineWithHitSlopPx(px int) LineOpt {
return func(o *LineOptions) { o.HitSlopPx = px }
}
-44
View File
@@ -1,44 +0,0 @@
package world
// PivotZoomCameraNoWrap adjusts camera center so that the world point under the cursor remains fixed
// when zoom changes from oldZoomFp to newZoomFp.
//
// Coordinate conventions:
// - CameraXWorldFp/YWorldFp is the center of the viewport in world-fixed units.
// - cursorXPx/cursorYPx are pixel coordinates relative to the top-left of the viewport.
// - viewportW/H are viewport size in pixels.
//
// This function does not clamp the result; caller should clamp for no-wrap mode using ClampCameraNoWrapViewport.
func PivotZoomCameraNoWrap(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
cursorXPx, cursorYPx int,
oldZoomFp, newZoomFp int,
) (newCamX, newCamY int) {
if oldZoomFp <= 0 || newZoomFp <= 0 {
panic("PivotZoomCameraNoWrap: invalid zoom")
}
if viewportW <= 0 || viewportH <= 0 {
panic("PivotZoomCameraNoWrap: invalid viewport")
}
// Offset of cursor from viewport center in pixels.
offXPx := cursorXPx - viewportW/2
offYPx := cursorYPx - viewportH/2
// World-fixed per 1 pixel at each zoom.
// (Conservative: integer arithmetic, consistent with PixelSpanToWorldFixed.)
oldWorldPerPx := PixelSpanToWorldFixed(1, oldZoomFp)
newWorldPerPx := PixelSpanToWorldFixed(1, newZoomFp)
// World point under cursor before zoom:
// world = camera + offsetPx * worldPerPx
worldX := cameraXWorldFp + offXPx*oldWorldPerPx
worldY := cameraYWorldFp + offYPx*oldWorldPerPx
// Choose new camera so that the same world point stays under cursor:
// camera' = world - offsetPx * newWorldPerPx
newCamX = worldX - offXPx*newWorldPerPx
newCamY = worldY - offYPx*newWorldPerPx
return
}
-50
View File
@@ -1,50 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPivotZoom_CursorAtCenter_KeepsCamera(t *testing.T) {
t.Parallel()
cx, cy := PivotZoomCameraNoWrap(
50*SCALE, 60*SCALE,
100, 80,
50, 40, // cursor at center
SCALE, 2*SCALE,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
func TestPivotZoom_RightEdge_ZoomInMovesCameraRight(t *testing.T) {
t.Parallel()
// viewport 100px, cursor at x=100 (right edge), center is 50 => offX=50px
// zoom 1->2 halves worldPerPx, so camera must move towards the cursor to keep the same world point.
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
100, 50,
SCALE, 2*SCALE,
)
require.Greater(t, cx, cx0)
}
func TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft(t *testing.T) {
t.Parallel()
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
0, 50,
SCALE, 2*SCALE,
)
require.Less(t, cx, cx0)
}
-114
View File
@@ -1,114 +0,0 @@
package world
// PrimitiveID is a compact stable identifier for primitives stored in the World.
// It is allocated by the World and may be reused after deletion (free-list).
type PrimitiveID uint32
// MapItem is the common interface implemented by all world primitives.
type MapItem interface {
ID() PrimitiveID
}
type styleBase uint8
const (
styleBaseFixed styleBase = iota
styleBaseThemeLine
styleBaseThemeCircle
styleBaseThemePoint
)
// Point is a point primitive in fixed-point world coordinates.
type Point struct {
Id PrimitiveID
X, Y int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class PointClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Line is a line segment primitive in fixed-point world coordinates.
type Line struct {
Id PrimitiveID
X1, Y1 int
X2, Y2 int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class LineClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Circle is a circle primitive in fixed-point world coordinates.
type Circle struct {
Id PrimitiveID
X, Y int
Radius int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class CircleClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// ID returns the point identifier.
func (p Point) ID() PrimitiveID { return p.Id }
// ID returns the line identifier.
func (l Line) ID() PrimitiveID { return l.Id }
// ID returns the circle identifier.
func (c Circle) ID() PrimitiveID { return c.Id }
// MinX returns the minimum X endpoint coordinate of the line.
func (l Line) MinX() int { return min(l.X1, l.X2) }
// MaxX returns the maximum X endpoint coordinate of the line.
func (l Line) MaxX() int { return max(l.X1, l.X2) }
// MinY returns the minimum Y endpoint coordinate of the line.
func (l Line) MinY() int { return min(l.Y1, l.Y2) }
// MaxY returns the maximum Y endpoint coordinate of the line.
func (l Line) MaxY() int { return max(l.Y1, l.Y2) }
// MinX returns the minimum X coordinate of the circle bbox.
func (c Circle) MinX() int { return c.X - c.Radius }
// MaxX returns the maximum X coordinate of the circle bbox.
func (c Circle) MaxX() int { return c.X + c.Radius }
// MinY returns the minimum Y coordinate of the circle bbox.
func (c Circle) MinY() int { return c.Y - c.Radius }
// MaxY returns the maximum Y coordinate of the circle bbox.
func (c Circle) MaxY() int { return c.Y + c.Radius }
-72
View File
@@ -1,72 +0,0 @@
package world
import (
"testing"
)
func TestPrimitiveIDs(t *testing.T) {
t.Parallel()
id1 := PrimitiveID(1)
id2 := PrimitiveID(2)
id3 := PrimitiveID(3)
p := Point{Id: id1}
l := Line{Id: id2}
c := Circle{Id: id3}
if got := p.ID(); got != id1 {
t.Fatalf("Point.ID() = %v, want %v", got, id1)
}
if got := l.ID(); got != id2 {
t.Fatalf("Line.ID() = %v, want %v", got, id2)
}
if got := c.ID(); got != id3 {
t.Fatalf("Circle.ID() = %v, want %v", got, id3)
}
}
func TestLineMinMax(t *testing.T) {
t.Parallel()
l := Line{
X1: 7000, Y1: 2000,
X2: 1000, Y2: 9000,
}
if got := l.MinX(); got != 1000 {
t.Fatalf("Line.MinX() = %d, want 1000", got)
}
if got := l.MaxX(); got != 7000 {
t.Fatalf("Line.MaxX() = %d, want 7000", got)
}
if got := l.MinY(); got != 2000 {
t.Fatalf("Line.MinY() = %d, want 2000", got)
}
if got := l.MaxY(); got != 9000 {
t.Fatalf("Line.MaxY() = %d, want 9000", got)
}
}
func TestCircleBounds(t *testing.T) {
t.Parallel()
c := Circle{
X: 4000,
Y: 7000,
Radius: 1500,
}
if got := c.MinX(); got != 2500 {
t.Fatalf("Circle.MinX() = %d, want 2500", got)
}
if got := c.MaxX(); got != 5500 {
t.Fatalf("Circle.MaxX() = %d, want 5500", got)
}
if got := c.MinY(); got != 5500 {
t.Fatalf("Circle.MinY() = %d, want 5500", got)
}
if got := c.MaxY(); got != 8500 {
t.Fatalf("Circle.MaxY() = %d, want 8500", got)
}
}
-67
View File
@@ -1,67 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
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
}
+1393 -3
View File
File diff suppressed because it is too large Load Diff
-190
View File
@@ -1,190 +0,0 @@
package world
import (
"image"
)
func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) {
if gd, ok := drawer.(*GGDrawer); ok {
if gd.drawBackgroundFast(w, params, rect) {
return
}
}
th := w.Theme()
bgImg := th.BackgroundImage()
if bgImg == nil {
return
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
// Clamp rect to canvas.
if rect.W <= 0 || rect.H <= 0 {
return
}
if rect.X < 0 {
rect.W += rect.X
rect.X = 0
}
if rect.Y < 0 {
rect.H += rect.Y
rect.Y = 0
}
if rect.X+rect.W > canvasW {
rect.W = canvasW - rect.X
}
if rect.Y+rect.H > canvasH {
rect.H = canvasH - rect.Y
}
if rect.W <= 0 || rect.H <= 0 {
return
}
imgB := bgImg.Bounds()
imgW := imgB.Dx()
imgH := imgB.Dy()
if imgW <= 0 || imgH <= 0 {
return
}
tileMode := th.BackgroundTileMode()
anchor := th.BackgroundAnchorMode()
scaleMode := th.BackgroundScaleMode()
// Compute scaled tile size.
tileW, tileH := backgroundScaledSize(imgW, imgH, canvasW, canvasH, scaleMode)
if tileW <= 0 || tileH <= 0 {
return
}
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
drawer.Save()
drawer.ResetClip()
drawer.ClipRect(float64(rect.X), float64(rect.Y), float64(rect.W), float64(rect.H))
switch tileMode {
case BackgroundTileNone:
// Center image within full canvas (not within rect), then clip handles partial.
// This is important so that dirty redraw matches full redraw.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
case BackgroundTileRepeat:
originX := offX
originY := offY
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
for xx := startX; xx < rect.X+rect.W; xx += tileW {
drawBackgroundOne(drawer, bgImg, xx, yy, imgW, imgH, tileW, tileH, scaleMode)
}
}
default:
// Fallback: behave like none.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
}
drawer.Restore()
}
func drawBackgroundOne(drawer PrimitiveDrawer, img image.Image, x, y, srcW, srcH, dstW, dstH int, scaleMode BackgroundScaleMode) {
if scaleMode == BackgroundScaleNone && dstW == srcW && dstH == srcH {
drawer.DrawImage(img, x, y)
return
}
// For Fit/Fill, or if dst size differs, draw scaled.
drawer.DrawImageScaled(img, x, y, dstW, dstH)
}
// backgroundScaledSize computes uniform scaled destination size for the background image.
// For None: returns source size.
// For Fit: fits inside canvas.
// For Fill: covers canvas.
func backgroundScaledSize(srcW, srcH, canvasW, canvasH int, mode BackgroundScaleMode) (int, int) {
if srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0 {
return 0, 0
}
switch mode {
case BackgroundScaleNone:
return srcW, srcH
case BackgroundScaleFit, BackgroundScaleFill:
// Uniform scale: choose ratio based on min/max.
// Use integer math to avoid float; keep it stable across frames.
// We compute scale as rational and then round destination size.
// Let scale = canvasW/srcW vs canvasH/srcH.
// Fit uses min(scaleW, scaleH). Fill uses max(scaleW, scaleH).
//
// We'll compute dstW = round(srcW*scale), dstH = round(srcH*scale).
// Using float64 here is acceptable: this is UI-only and deterministic enough, and we already use gg float.
scaleW := float64(canvasW) / float64(srcW)
scaleH := float64(canvasH) / float64(srcH)
scale := scaleW
if mode == BackgroundScaleFit {
if scaleH < scale {
scale = scaleH
}
} else {
if scaleH > scale {
scale = scaleH
}
}
dstW := int(scale*float64(srcW) + 0.5)
dstH := int(scale*float64(srcH) + 0.5)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
return dstW, dstH
default:
return srcW, srcH
}
}
// backgroundAnchorOffsetPx computes a stable pixel offset for background anchoring.
// - Viewport anchor: offset is always 0 (background fixed to viewport/canvas pixels).
// - World anchor: offset depends on camera world position and zoom so that background moves with pan.
func (w *World) backgroundAnchorOffsetPx(params RenderParams, tileW, tileH int, anchor BackgroundAnchorMode) (int, int) {
if anchor == BackgroundAnchorViewport {
return 0, 0
}
zoomFp, err := params.CameraZoomFp()
if err != nil || zoomFp <= 0 {
return 0, 0
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
spanH := PixelSpanToWorldFixed(canvasH, zoomFp)
worldLeft := params.CameraXWorldFp - spanW/2
worldTop := params.CameraYWorldFp - spanH/2
pxX := worldSpanFixedToCanvasPx(worldLeft, zoomFp)
pxY := worldSpanFixedToCanvasPx(worldTop, zoomFp)
if tileW > 0 {
pxX = -wrap(pxX, tileW)
}
if tileH > 0 {
pxY = -wrap(pxY, tileH)
}
return pxX, pxY
}
@@ -1,151 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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
}
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)
}
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
}
@@ -1,95 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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
}
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"))
}
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"))
}
@@ -1,95 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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
}
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"))
}
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"))
}
-122
View File
@@ -1,122 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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
}
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)
}
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"))
}
+411
View File
@@ -0,0 +1,411 @@
package world
import (
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
"image"
"image/color"
"testing"
)
type benchBgTheme struct {
img image.Image
anchor BackgroundAnchorMode
tileMode BackgroundTileMode
scaleMode BackgroundScaleMode
}
func (t benchBgTheme) ID() string { return "benchbg" }
func (t benchBgTheme) Name() string { return "benchbg" }
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
func (t benchBgTheme) PointStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t benchBgTheme) LineStyle() Style {
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background.
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
// Some primitives to keep it realistic but not dominant.
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
// Initial render (commit state).
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
}
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
// Background tile (RGBA) — typical texture size.
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
// Make it semi-transparent so draw.Over has real work.
for y := 0; y < 96; y++ {
for x := 0; x < 96; x++ {
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
}
}
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG.
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
// Make a lot of lines, including ones that likely wrap.
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake.
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600)
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
drawer := &fakePrimitiveDrawer{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Reset command log so it doesn't grow forever and dominate allocations.
drawer.Reset()
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips.
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
w.resetGrid(2 * SCALE)
_, _ = w.AddPoint(5, 5)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
},
}
// First render initializes state.
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params))
// Small pan.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params2))
// Expect very few ClipRect calls (dirty strips count), not per tile.
clipCmds := d2.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
require.LessOrEqual(t, len(clipCmds), 4)
}
// TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID.
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
// Two lines with default style, same priority.
_, _ = w.AddLine(1, 1, 8, 1)
_, _ = w.AddLine(1, 2, 8, 2)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
adds := d.CommandsByName("AddLine")
strokes := d.CommandsByName("Stroke")
require.GreaterOrEqual(t, len(adds), 2)
require.GreaterOrEqual(t, len(strokes), 1)
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
// (Keep it loose to avoid depending on tile partitioning.)
}
// BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse.
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
w := NewWorld(600, 600)
// Make grid + index available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Add enough objects so tiles have candidates.
for i := range 2000 {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := range 500 {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// We don't clear here; we only measure the draw loop overhead.
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates.
func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) {
w := NewWorld(600, 600)
// Make the index/grid available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Populate with enough objects to create duplicates across cells.
// Circles and lines create bbox indexing (more duplicates).
for i := 0; i < 2000; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := 0; i < 1200; i++ {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0)
}
for i := 0; i < 1200; i++ {
x1 := float64((i*3 + 10) % 600)
y1 := float64((i*5 + 20) % 600)
x2 := float64((i*7 + 400) % 600)
y2 := float64((i*11 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
}
}
-180
View File
@@ -1,180 +0,0 @@
package world
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool, circleRadiusScaleFp int) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Filter only circles; skip tiles that have no circles.
circles := make([]Circle, 0, len(td.Candidates))
for _, it := range td.Candidates {
c, ok := it.(Circle)
if !ok {
continue
}
circles = append(circles, c)
}
if len(circles) == 0 {
continue
}
// Determine which circle copies actually intersect this tile segment.
type circleCopy struct {
c Circle
dx int
dy int
}
copiesToDraw := make([]circleCopy, 0, len(circles))
for _, c := range circles {
var shifts []wrapShift
effRadius := circleRadiusEffFp(c.Radius, circleRadiusScaleFp)
if allowWrap {
shifts = circleWrapShifts(c.X, c.Y, effRadius, worldW, worldH)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, worldW, worldH) {
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, cc := range copiesToDraw {
c := cc.c
// Project the circle center for this tile copy (tile offset + wrap shift).
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+cc.dx)-plan.WorldRect.minX, plan.ZoomFp)
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+cc.dy)-plan.WorldRect.minY, plan.ZoomFp)
// Radius is a world span.
rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp)
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
}
drawer.Fill()
drawer.Restore()
}
}
type wrapShift struct {
dx int
dy int
}
// circleWrapShiftsInto appends required torus-copy shifts for a circle into dst and returns the resulting slice.
// It never allocates if dst has enough capacity.
//
// The 0-shift is always included. Additional copies are included when the circle's bbox crosses world edges.
func circleWrapShiftsInto(dst []wrapShift, cx, cy, radiusFp, worldW, worldH int) []wrapShift {
dst = dst[:0]
// Always include the original.
dst = append(dst, wrapShift{dx: 0, dy: 0})
if radiusFp <= 0 {
return dst
}
minX := cx - radiusFp
maxX := cx + radiusFp
minY := cy - radiusFp
maxY := cy + radiusFp
needLeft := minX < 0
needRight := maxX > worldW
needTop := minY < 0
needBottom := maxY > worldH
// X-only copies.
if needLeft {
dst = append(dst, wrapShift{dx: +worldW, dy: 0})
}
if needRight {
dst = append(dst, wrapShift{dx: -worldW, dy: 0})
}
// Y-only copies.
if needTop {
dst = append(dst, wrapShift{dx: 0, dy: +worldH})
}
if needBottom {
dst = append(dst, wrapShift{dx: 0, dy: -worldH})
}
// Corner copies (combine X and Y).
if (needLeft || needRight) && (needTop || needBottom) {
var dxs [2]int
dxn := 0
if needLeft {
dxs[dxn] = +worldW
dxn++
}
if needRight {
dxs[dxn] = -worldW
dxn++
}
var dys [2]int
dyn := 0
if needTop {
dys[dyn] = +worldH
dyn++
}
if needBottom {
dys[dyn] = -worldH
dyn++
}
for i := 0; i < dxn; i++ {
for j := 0; j < dyn; j++ {
dst = append(dst, wrapShift{dx: dxs[i], dy: dys[j]})
}
}
}
return dst
}
// circleWrapShifts is a compatibility wrapper that allocates.
// Prefer circleWrapShiftsInto in hot paths.
func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift {
var dst []wrapShift
return circleWrapShiftsInto(dst, cx, cy, radiusFp, worldW, worldH)
}
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis.
func circleCopyIntersectsTile(cx, cy, radiusFp, dx, dy int, tile WorldTile, worldW, worldH int) bool {
// Unwrapped tile segment bounds.
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
// Circle bbox in the same unwrapped space (apply shift + tile offset).
cx = cx + tile.OffsetX + dx
cy = cy + tile.OffsetY + dy
minX := cx - radiusFp
maxX := cx + radiusFp
minY := cy - radiusFp
maxY := cy + radiusFp
// Treat bbox as half-open for intersection checks.
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}
-298
View File
@@ -1,298 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
t.Parallel()
// World is 10x10 world units => 10000x10000 fixed.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Circle near origin so that in expanded canvas (bigger than world)
// it will appear in multiple torus tiles.
id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000
require.NoError(t, err)
w.indexObject(w.objects[id])
// Same geometry as points-only test:
// viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H, 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)
}
}
func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Add only a point, no circles.
id, err := w.AddPoint(5, 5)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
// No circles => no commands.
require.Empty(t, d.Commands())
}
func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
w.resetGrid(10 * SCALE)
// radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1.
id, err := w.AddCircle(50, 50, 2)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 50 * SCALE,
CameraYWorldFp: 50 * SCALE,
CameraZoom: 2.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H, 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])
}
}
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)
}
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")
}
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")
}
@@ -1,94 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
t.Parallel()
// World 10x10 units => 10px at zoom=1 when viewport==world.
w := NewWorld(10, 10)
w.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.buildRenderPlanStageA(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)
})
}
}
-230
View File
@@ -1,230 +0,0 @@
package world
import (
"sort"
)
// drawKind is used only for stable tie-breaking when priorities are equal.
type drawKind int
const (
drawKindLine drawKind = iota
drawKindCircle
drawKindPoint
)
type drawItem struct {
kind drawKind
priority int
id PrimitiveID
styleID StyleID
// Exactly one of these is set.
p Point
c Circle
l Line
}
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism.
//
// allowWrap controls torus behavior:
// - true: circles/points produce wrap copies, lines use torus-shortest segments
// - false: no copies, lines drawn directly as stored
// tileClipEnabled controls whether per-tile ClipRect is applied.
// When an outer clip is already set (e.g. dirty rect), disable tile clips for speed.
func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool, tileClipEnabled bool, isDirtyPass bool) {
var lastStyleID StyleID = StyleIDInvalid
var lastStyle Style
applyStyle := func(styleID StyleID) {
if styleID == lastStyleID {
return
}
s, ok := w.styles.Get(styleID)
if !ok {
panic("render: unknown style ID")
}
if s.FillColor != nil {
drawer.SetFillColor(s.FillColor)
}
if s.StrokeColor != nil {
drawer.SetStrokeColor(s.StrokeColor)
}
drawer.SetLineWidth(s.StrokeWidthPx)
if len(s.StrokeDashes) > 0 {
drawer.SetDash(s.StrokeDashes...)
} else {
drawer.SetDash()
}
drawer.SetDashOffset(s.StrokeDashOffset)
lastStyleID = styleID
lastStyle = s
}
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Per-tile clip is optional. When outer-clip is used (dirty rect),
// tileClipEnabled must be false to avoid resetting the outer clip.
if tileClipEnabled {
drawer.Save()
drawer.ResetClip()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
}
items := w.scratchDrawItems[:0]
if cap(items) < len(td.Candidates) {
items = make([]drawItem, 0, len(td.Candidates))
}
for _, it := range td.Candidates {
id := it.ID()
cur, ok := w.objects[id]
if !ok {
continue
}
switch v := cur.(type) {
case Point:
items = append(items, drawItem{
kind: drawKindPoint,
priority: v.Priority,
id: v.Id,
styleID: v.StyleID,
p: v,
})
case Circle:
items = append(items, drawItem{
kind: drawKindCircle,
priority: v.Priority,
id: v.Id,
styleID: v.StyleID,
c: v,
})
case Line:
items = append(items, drawItem{
kind: drawKindLine,
priority: v.Priority,
id: v.Id,
styleID: v.StyleID,
l: v,
})
default:
panic("render: unknown map item type")
}
}
if len(items) == 0 {
if tileClipEnabled {
drawer.Restore()
}
w.scratchDrawItems = items[:0]
continue
}
sort.Slice(items, func(i, j int) bool {
a, b := items[i], items[j]
if a.priority != b.priority {
return a.priority < b.priority
}
if a.kind != b.kind {
return a.kind < b.kind
}
return a.id < b.id
})
// If this is not a dirty pass (full redraw), keep the old behavior for lines:
// stroke per segment. This is usually faster for gg on huge scenes.
if !isDirtyPass {
for i := 0; i < len(items); i++ {
di := items[i]
applyStyle(di.styleID)
switch di.kind {
case drawKindPoint:
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
case drawKindCircle:
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
case drawKindLine:
// Old behavior: drawLineInTile includes Stroke() per segment.
w.drawLineInTile(drawer, plan, td, di.l, allowWrap)
default:
panic("render: unknown draw kind")
}
}
} else {
// Dirty pass: batch lines to reduce overhead while panning.
inLineRun := false
var lineRunStyleID StyleID
lineSegCount := 0
flushLineRun := func() {
if !inLineRun {
return
}
drawer.Stroke()
inLineRun = false
lineSegCount = 0
}
for i := 0; i < len(items); i++ {
di := items[i]
if inLineRun {
if di.kind != drawKindLine || di.styleID != lineRunStyleID {
flushLineRun()
}
}
switch di.kind {
case drawKindLine:
if !inLineRun {
lineRunStyleID = di.styleID
applyStyle(lineRunStyleID)
inLineRun = true
} else {
// style matches by construction; keep style state valid if code changes later
applyStyle(di.styleID)
}
added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap)
lineSegCount += added
if lineSegCount >= maxLineSegmentsPerStroke {
drawer.Stroke()
lineSegCount = 0
// keep run active
}
case drawKindPoint:
flushLineRun()
applyStyle(di.styleID)
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
case drawKindCircle:
flushLineRun()
applyStyle(di.styleID)
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
default:
flushLineRun()
panic("render: unknown draw kind")
}
}
flushLineRun()
}
if tileClipEnabled {
drawer.Restore()
}
// Reuse buffer for next tile.
w.scratchDrawItems = items[:0]
}
}
@@ -1,169 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
)
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
// Make a lot of lines, including ones that likely wrap.
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600)
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
drawer := &fakePrimitiveDrawer{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Reset command log so it doesn't grow forever and dominate allocations.
drawer.Reset()
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
w.resetGrid(2 * SCALE)
_, _ = w.AddPoint(5, 5)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
},
}
// First render initializes state.
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params))
// Small pan.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params2))
// Expect very few ClipRect calls (dirty strips count), not per tile.
clipCmds := d2.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
require.LessOrEqual(t, len(clipCmds), 4)
}
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
// Two lines with default style, same priority.
_, _ = w.AddLine(1, 1, 8, 1)
_, _ = w.AddLine(1, 2, 8, 2)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
adds := d.CommandsByName("AddLine")
strokes := d.CommandsByName("Stroke")
require.GreaterOrEqual(t, len(adds), 2)
require.GreaterOrEqual(t, len(strokes), 1)
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
// (Keep it loose to avoid depending on tile partitioning.)
}
-137
View File
@@ -1,137 +0,0 @@
package world
// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn.
// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting.
type lineSeg struct {
x1, y1 int
x2, y2 int
}
// drawPointInTile draws point marker copies that intersect the tile.
// lastStyle is already applied; it provides PointRadiusPx.
func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) {
rPx := lastStyle.PointRadiusPx
if rPx <= 0 {
// Nothing visible.
return
}
// Convert screen radius to world-fixed conservatively.
rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp)
var shifts []wrapShift
if allowWrap {
shifts = pointWrapShifts(p, rWorldFp, w.W, w.H)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
continue
}
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddPoint(float64(px), float64(py), rPx)
fill := alphaNonZero(lastStyle.FillColor)
stroke := alphaNonZero(lastStyle.StrokeColor)
if fill {
drawer.Fill()
}
if stroke {
// Stroke must be last when both are present.
drawer.Stroke()
}
}
}
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
var shifts []wrapShift
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
if allowWrap {
shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H)
} else {
var one [1]wrapShift
one[0] = wrapShift{dx: 0, dy: 0}
shifts = one[:]
}
rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp)
for _, s := range shifts {
if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) {
continue
}
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
fill := alphaNonZero(lastStyle.FillColor)
stroke := alphaNonZero(lastStyle.StrokeColor)
switch {
case fill && stroke:
// gg consumes the current path on Fill/Stroke, so we must draw twice:
// once for fill, then again for stroke.
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
drawer.Fill()
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
drawer.Stroke()
case fill:
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
drawer.Fill()
case stroke:
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
drawer.Stroke()
default:
// neither visible => nothing
}
}
w.scratchWrapShifts = shifts[:0]
}
func (w *World) drawLineInTilePath(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) int {
segs := w.scratchLineSegs[:0]
tmp := w.scratchLineSegsTmp[:0]
if cap(segs) < 4 {
segs = make([]lineSeg, 0, 4)
}
if cap(tmp) < 4 {
tmp = make([]lineSeg, 0, 4)
}
if allowWrap {
segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H)
} else {
var one [1]lineSeg
one[0] = lineSeg{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}
segs = one[:]
}
for _, s := range segs {
x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2))
}
w.scratchLineSegs = segs[:0]
w.scratchLineSegsTmp = tmp[:0]
return len(segs)
}
func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) {
w.drawLineInTilePath(drawer, plan, td, l, allowWrap)
drawer.Stroke()
}
@@ -1,53 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/fogleman/gg"
)
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
w := NewWorld(600, 600)
// Make grid + index available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Add enough objects so tiles have candidates.
for i := range 2000 {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := range 500 {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// We don't clear here; we only measure the draw loop overhead.
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
-36
View File
@@ -1,36 +0,0 @@
package world
func (w *World) candSeenResetIfOverflow() {
w.candEpoch++
if w.candEpoch != 0 {
return
}
// overflow: reset stamp array
for i := range w.candStamp {
w.candStamp[i] = 0
}
w.candEpoch = 1
}
func (w *World) candSeenMark(id PrimitiveID) bool {
// ensure stamp capacity
uid := uint32(id)
if int(uid) >= len(w.candStamp) {
// grow to next power-ish
n := len(w.candStamp)
if n == 0 {
n = 1024
}
for n <= int(uid) {
n *= 2
}
ns := make([]uint32, n)
copy(ns, w.candStamp)
w.candStamp = ns
}
if w.candStamp[uid] == w.candEpoch {
return true
}
w.candStamp[uid] = w.candEpoch
return false
}
@@ -1,190 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1, // 1ms budget
},
},
}
// First render (full) initializes state.
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
require.True(t, w.renderState.initialized)
// Pretend previous render was very slow => over budget for the next frame.
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
// Pan right by 1 unit => incremental shift candidate.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params2))
// Shift-only should call CopyShift but not redraw dirty rects.
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
require.Empty(t, d1.CommandsByName("ClipRect"))
require.NotEmpty(t, w.renderState.pendingDirty)
// Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty.
w.renderState.lastRenderDurationNs = 0 // under budget
params3 := params2 // same camera
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params3))
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
require.Empty(t, w.renderState.pendingDirty)
}
func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
policy := &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1,
}
base := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: policy,
},
}
// Initial full render.
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
// Frame 1: over budget => shift-only, pendingDirty accumulates.
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
p1 := base
p1.CameraXWorldFp += 1 * SCALE
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, p1))
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
require.Empty(t, d1.CommandsByName("ClipRect"))
require.NotEmpty(t, w.renderState.pendingDirty)
// Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty).
w.renderState.lastRenderDurationNs = 0
p2 := p1
p2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, p2))
require.NotEmpty(t, d2.CommandsByName("CopyShift"))
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw")
}
func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
policy := &IncrementalPolicy{
AllowShiftOnly: true,
RenderBudgetMs: 1,
MaxCatchUpAreaPx: 20, // very small budget
}
base := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 2,
MarginXPx: 4,
MarginYPx: 4, // canvasH = 2 + 8 = 10
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: policy,
},
}
// Full init
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
// Over budget => shift-only twice to accumulate pending dirty.
w.renderState.lastRenderDurationNs = 10_000_000
p1 := base
p1.CameraXWorldFp += 1 * SCALE
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1))
require.NotEmpty(t, w.renderState.pendingDirty)
w.renderState.lastRenderDurationNs = 10_000_000
p2 := p1
p2.CameraXWorldFp += 1 * SCALE
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
require.NotEmpty(t, w.renderState.pendingDirty)
// Under budget now, but limit catch-up.
w.renderState.lastRenderDurationNs = 0
before := len(w.renderState.pendingDirty)
require.Greater(t, before, 0)
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
after := len(w.renderState.pendingDirty)
// With a tiny MaxCatchUpAreaPx we should not clear everything in one go.
require.Greater(t, after, 0)
require.Less(t, after, before)
}
@@ -1,38 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) {
t.Parallel()
pending := []RectPx{
{X: 0, Y: 0, W: 10, H: 1}, // area 10
{X: 0, Y: 1, W: 10, H: 2}, // area 20
{X: 0, Y: 3, W: 10, H: 3}, // area 30
}
// Limit 25 => should take first (10) + second (20) would exceed => take only first.
sel, rem := takeCatchUpRects(pending, 25)
require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel)
require.Equal(t, []RectPx{
{X: 0, Y: 1, W: 10, H: 2},
{X: 0, Y: 3, W: 10, H: 3},
}, rem)
// Limit 30 => can take first(10) + second(20) exactly.
sel, rem = takeCatchUpRects(pending, 30)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 10, H: 1},
{X: 0, Y: 1, W: 10, H: 2},
}, sel)
require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem)
// No limit => take all.
sel, rem = takeCatchUpRects(pending, 0)
require.Len(t, sel, 3)
require.Empty(t, rem)
}
-265
View File
@@ -1,265 +0,0 @@
package world
import "errors"
var (
errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
)
// IncrementalMode describes how the renderer should update the backing image.
type IncrementalMode int
const (
// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
IncrementalNoOp IncrementalMode = iota
// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
IncrementalShift
// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
IncrementalFullRedraw
)
// RectPx is an integer rectangle in canvas pixel coordinates.
// Semantics are half-open: [X, X+W) x [Y, Y+H).
type RectPx struct {
X, Y int
W, H int
}
// IncrementalPolicy is a placeholder for future incremental tuning.
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
type IncrementalPolicy struct {
// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
// This will be implemented later; kept here as a placeholder to lock the API shape.
CoalesceUpdates bool
// AllowShiftOnly allows a temporary mode where the backing image is shifted
// but dirty rects are not redrawn immediately under overload.
AllowShiftOnly bool
// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
RenderBudgetMs int
// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
// 0 means "no limit".
MaxCatchUpAreaPx int
}
// IncrementalPlan is the output of pure incremental planning.
// It does not perform any drawing. It only describes what should happen.
type IncrementalPlan struct {
Mode IncrementalMode
// Shift to apply to the backing image in canvas pixels.
// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
// Positive dy shifts the existing image down (exposing a dirty strip on the top).
DxPx int
DyPx int
// Dirty rects to redraw after shifting (in canvas pixel coordinates).
// Rects may overlap; overlapping is allowed and simplifies planning.
Dirty []RectPx
}
// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
// and redrawing only exposed strips, or must fall back to a full redraw.
//
// Threshold rule (per-axis):
// - If abs(dxPx) > marginXPx/2 => full redraw
// - If abs(dyPx) > marginYPx/2 => full redraw
//
// Additional safety rules:
// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
//
// Returned dirty rects follow the chosen shift direction:
//
// dxPx > 0 => dirty strip on the left (width=dxPx)
// dxPx < 0 => dirty strip on the right (width=-dxPx)
// dyPx > 0 => dirty strip on the top (height=dyPx)
// dyPx < 0 => dirty strip on the bottom(height=-dyPx)
func PlanIncrementalPan(
canvasW, canvasH int,
marginXPx, marginYPx int,
dxPx, dyPx int,
) (IncrementalPlan, error) {
if canvasW <= 0 || canvasH <= 0 {
return IncrementalPlan{}, errInvalidCanvasSize
}
if marginXPx < 0 || marginYPx < 0 {
return IncrementalPlan{}, errors.New("incremental: invalid margins")
}
// No movement => no work.
if dxPx == 0 && dyPx == 0 {
return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
}
adx := abs(dxPx)
ady := abs(dyPx)
// Too large shift cant be represented as "shift + stripes".
if adx >= canvasW || ady >= canvasH {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// Thresholds: per axis, independently.
// Using integer division: margin/2 truncates down, which is fine and deterministic.
thrX := marginXPx / 2
thrY := marginYPx / 2
if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// If margin is 0, thr is 0, and any non-zero delta should force full redraw
// (because we have no buffer area to shift into).
if marginXPx == 0 && dxPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
if marginYPx == 0 && dyPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
dirty := make([]RectPx, 0, 2)
// Horizontal exposed strip with 1px overdraw to avoid seams.
if dxPx > 0 {
// Image moved right => left strip is exposed.
w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
} else if dxPx < 0 {
// Image moved left => right strip is exposed.
w := min((-dxPx)+1, canvasW)
dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
}
// Vertical exposed strip with 1px overdraw to avoid seams.
if dyPx > 0 {
// Image moved down => top strip is exposed.
h := min(dyPx+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
} else if dyPx < 0 {
// Image moved up => bottom strip is exposed.
h := min((-dyPx)+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
}
// Filter out any zero/negative rects defensively.
out := dirty[:0]
for _, r := range dirty {
if r.W <= 0 || r.H <= 0 {
continue
}
out = append(out, r)
}
return IncrementalPlan{
Mode: IncrementalShift,
DxPx: dxPx,
DyPx: dyPx,
Dirty: out,
}, nil
}
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
return inter, ok
}
// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
// TileDrawPlan entry with the clip replaced by the intersection.
//
// This makes drawing functions naturally render only the dirty areas.
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
if len(dirty) == 0 {
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: nil,
}
}
outTiles := make([]TileDrawPlan, 0)
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}
for _, dr := range dirty {
if isEmptyRectPx(dr) {
continue
}
inter, ok := intersectRectPx(tileClip, dr)
if !ok {
continue
}
outTiles = append(outTiles, TileDrawPlan{
Tile: td.Tile,
ClipX: inter.X,
ClipY: inter.Y,
ClipW: inter.W,
ClipH: inter.H,
Candidates: td.Candidates,
})
}
}
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: outTiles,
}
}
// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
if len(pending) == 0 {
return nil, nil
}
if maxAreaPx <= 0 {
// No limit.
all := append([]RectPx(nil), pending...)
return all, nil
}
selected = make([]RectPx, 0, len(pending))
remaining = make([]RectPx, 0)
used := 0
for _, r := range pending {
if r.W <= 0 || r.H <= 0 {
continue
}
area := r.W * r.H
if area <= 0 {
continue
}
// If we cannot fit the whole rect, we stop (simple, deterministic).
// (We do not split rectangles here to keep logic simple.)
if used+area > maxAreaPx {
remaining = append(remaining, r)
continue
}
selected = append(selected, r)
used += area
}
// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
// Note: remaining preserves original order among non-selected entries.
return selected, remaining
}
@@ -1,144 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPlanIncrementalPan_NoOp(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0)
require.NoError(t, err)
require.Equal(t, IncrementalNoOp, plan.Mode)
require.Empty(t, plan.Dirty)
}
func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) {
t.Parallel()
_, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0)
require.ErrorIs(t, err, errInvalidCanvasSize)
}
func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) {
t.Parallel()
// marginX=20 => threshold=10, dx=11 => full redraw
plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) {
t.Parallel()
// marginY=20 => threshold=10, dy=-11 => full redraw
plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11)
require.NoError(t, err)
require.Equal(t, IncrementalFullRedraw, plan.Mode)
}
func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) {
t.Parallel()
// marginX=40 => threshold=20, dx=5 => shift ok
plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, 5, plan.DxPx)
require.Equal(t, 0, plan.DyPx)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 6, H: 100},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 200 - 8, Y: 0, W: 8, H: 100},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 0, Y: 0, W: 200, H: 10},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
require.Equal(t, []RectPx{
{X: 0, Y: 100 - 10, W: 200, H: 10},
}, plan.Dirty)
}
func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
// Overlap is allowed; we just require both strips exist.
require.Len(t, plan.Dirty, 2)
require.ElementsMatch(t, []RectPx{
{X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip
{X: 0, Y: 0, W: 200, H: 9}, // top strip
}, plan.Dirty)
}
func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) {
t.Parallel()
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
require.NoError(t, err)
require.Equal(t, IncrementalShift, plan.Mode)
// Right strip width should be abs(dx)+1 = 8.
require.Equal(t, []RectPx{
{X: 200 - 8, Y: 0, W: 8, H: 100},
}, plan.Dirty)
}
@@ -1,108 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
_, err = w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4, // threshold=2
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
// First render initializes state (full redraw).
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
// Pan right by 1 unit => dx=-1 => incremental shift expected.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d := &fakePrimitiveDrawer{}
err = w.Render(d, params2)
require.NoError(t, err)
// Must contain CopyShift for incremental path.
require.NotEmpty(t, d.CommandsByName("CopyShift"))
// All clip rects should be "small": width <= 1 for dx=-1 strip.
clipCmds := d.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
for _, c := range clipCmds {
wPx := int(c.Args[2])
hPx := int(c.Args[3])
require.LessOrEqual(t, wPx, 2)
require.LessOrEqual(t, hPx, params2.CanvasHeightPx())
}
require.NotEmpty(t, d.CommandsByName("AddPoint"))
require.NotEmpty(t, d.CommandsByName("AddCircle"))
require.NotEmpty(t, d.CommandsByName("AddLine"))
}
func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 4, // threshold=2
MarginYPx: 4,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d0 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d0, params))
// Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected.
params2 := params
params2.CameraXWorldFp += 3 * SCALE
d := &fakePrimitiveDrawer{}
err = w.Render(d, params2)
require.NoError(t, err)
// Full redraw should NOT call CopyShift.
require.Empty(t, d.CommandsByName("CopyShift"))
// 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"))
}
-202
View File
@@ -1,202 +0,0 @@
package world
import "errors"
var (
errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required")
errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required")
errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom")
errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size")
)
// rendererIncrementalState stores the minimum state needed for incremental pan.
type rendererIncrementalState struct {
initialized bool
// Last render geometry key.
lastZoomFp int
lastViewportW int
lastViewportH int
lastMarginX int
lastMarginY int
lastCanvasW int
lastCanvasH int
// Last unwrapped expanded world rect used for rendering.
lastWorldRect Rect
// Remainders in numerator space to make world->px conversion stable across many small pans.
// We keep them per axis and update them during conversion.
remXNum int64
remYNum int64
// Last measured render duration (nanoseconds). Used for overload heuristics.
lastRenderDurationNs int64
// Pending dirty areas accumulated during shift-only frames.
// These are in current canvas pixel coordinates.
pendingDirty []RectPx
}
// Reset clears incremental state, forcing next frame to use full redraw.
func (s *rendererIncrementalState) Reset() {
*s = rendererIncrementalState{}
}
// incrementalKeyFromParams extracts the geometry key that must match for incremental pan.
func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) {
vw = params.ViewportWidthPx
vh = params.ViewportHeightPx
mx = params.MarginXPx
my = params.MarginYPx
cw = params.CanvasWidthPx()
ch = params.CanvasHeightPx()
z = zoomFp
return
}
// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp,
// carrying a signed remainder in numerator space to avoid cumulative drift.
//
// The conversion is:
//
// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE))
//
// and rem is updated to the exact remainder.
//
// This function works for negative deltas too and uses floor division semantics.
func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int {
if zoomFp <= 0 {
panic("worldDeltaFixedToCanvasPx: invalid zoom")
}
den := int64(SCALE) * int64(SCALE)
num := int64(deltaWorldFp)*int64(zoomFp) + *remNum
q, r := floorDivRem64(num, den)
*remNum = r
return int(q)
}
// floorDivRem64 returns (q,r) such that:
//
// q = floor(a / b), r = a - q*b
//
// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder).
func floorDivRem64(a, b int64) (q int64, r int64) {
if b <= 0 {
panic("floorDivRem64: non-positive divisor")
}
q = a / b
r = a % b
if r != 0 && a < 0 {
q--
r = a - q*b
}
return q, r
}
// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image
// when ONLY camera pan changed (no zoom/viewport/margins changes).
//
// Returned dxPx/dyPx are shifts to apply to the already rendered image:
//
// dxPx > 0 => shift image right
// dxPx < 0 => shift image left
//
// This function updates internal incremental state when possible.
// If it returns an error, the caller should fall back to a full redraw and call
// CommitFullRedrawState afterward.
func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) {
zoomFp, zerr := params.CameraZoomFp()
if zerr != nil {
return 0, 0, zerr
}
if zoomFp <= 0 {
return 0, 0, errIncrementalInvalidZoomFp
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
if canvasW <= 0 || canvasH <= 0 {
return 0, 0, errIncrementalInvalidCanvasPx
}
newRect, rerr := params.ExpandedCanvasWorldRect()
if rerr != nil {
return 0, 0, rerr
}
s := &w.renderState
// First call: no prior state => must full redraw.
if !s.initialized {
return 0, 0, errIncrementalStateNotReady
}
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
if s.lastZoomFp != z ||
s.lastViewportW != vw || s.lastViewportH != vh ||
s.lastMarginX != mx || s.lastMarginY != my ||
s.lastCanvasW != cw || s.lastCanvasH != ch {
return 0, 0, errIncrementalZoomMismatch
}
// Compute how much the unwrapped world rect moved.
dMinX := newRect.minX - s.lastWorldRect.minX
dMinY := newRect.minY - s.lastWorldRect.minY
// Convert world movement to pixel movement of the world content.
// If world rect moved +X (camera moved right), content appears shifted left,
// so the old image must be shifted left: shiftPx = -deltaPx.
deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum)
deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum)
dxPx = -deltaPxX
dyPx = -deltaPxY
// Update stored rect for the next incremental computation.
s.lastWorldRect = newRect
return dxPx, dyPx, nil
}
// CommitFullRedrawState updates incremental state after a full redraw.
// Call this after you finish a full Render() that draws the entire expanded canvas.
func (w *World) CommitFullRedrawState(params RenderParams) error {
zoomFp, err := params.CameraZoomFp()
if err != nil {
return err
}
if zoomFp <= 0 {
return errIncrementalInvalidZoomFp
}
rect, err := params.ExpandedCanvasWorldRect()
if err != nil {
return err
}
s := &w.renderState
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
s.initialized = true
s.lastZoomFp = z
s.lastViewportW = vw
s.lastViewportH = vh
s.lastMarginX = mx
s.lastMarginY = my
s.lastCanvasW = cw
s.lastCanvasH = ch
s.lastWorldRect = rect
// Reset remainders on a full redraw to avoid stale accumulation when geometry changes.
s.remXNum = 0
s.remYNum = 0
s.pendingDirty = nil
return nil
}
@@ -1,171 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) {
t.Parallel()
// zoom=1: px = (deltaWorldFp * 1000) / 1e6
// For deltaWorldFp=1, each step contributes 0 px with remainder,
// and after 1000 steps it must become 1 px total.
zoomFp := SCALE
var rem int64
sum := 0
for i := 0; i < 1000; i++ {
sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem)
}
require.Equal(t, 1, sum)
}
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) {
t.Parallel()
zoomFp := SCALE
var rem int64
sum := 0
for i := 0; i < 1000; i++ {
sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem)
}
require.Equal(t, -1, sum)
}
func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
_, _, err := w.ComputePanShiftPx(params)
require.ErrorIs(t, err, errIncrementalStateNotReady)
}
func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
base := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(base))
changed := base
changed.CameraZoom = 2.0
_, _, err := w.ComputePanShiftPx(changed)
require.ErrorIs(t, err, errIncrementalZoomMismatch)
changed2 := base
changed2.ViewportWidthPx = 101
_, _, err = w.ComputePanShiftPx(changed2)
require.ErrorIs(t, err, errIncrementalZoomMismatch)
}
func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Move camera right by 1 world unit => world rect minX increases by 1 unit,
// so content moves left by 1px at zoom=1 => image shift should be -1.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
dx, dy, err := w.ComputePanShiftPx(params2)
require.NoError(t, err)
require.Equal(t, -1, dx)
require.Equal(t, 0, dy)
}
func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Move camera up by 1 world unit => world rect minY decreases by 1 unit,
// so content moves down by 1px => image shift should be +1 in dy.
params2 := params
params2.CameraYWorldFp -= 1 * SCALE
dx, dy, err := w.ComputePanShiftPx(params2)
require.NoError(t, err)
require.Equal(t, 0, dx)
require.Equal(t, 1, dy)
}
func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
require.NoError(t, w.CommitFullRedrawState(params))
// Pan camera right by 0.001 world units (1 fixed-point) 1000 times.
// At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1.
totalDx := 0
p := params
for i := 0; i < 1000; i++ {
p.CameraXWorldFp += 1
dx, dy, err := w.ComputePanShiftPx(p)
require.NoError(t, err)
require.Equal(t, 0, dy)
totalDx += dx
}
require.Equal(t, -1, totalDx)
}
-69
View File
@@ -1,69 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
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])
}
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)
}
-113
View File
@@ -1,113 +0,0 @@
package world
// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams.
// It is a pure description: it does not execute any drawing.
type RenderPlan struct {
CanvasWidthPx int
CanvasHeightPx int
ZoomFp int
// WorldRect is the unwrapped world-space rect covered by the expanded canvas.
WorldRect Rect
// Tiles are ordered in the same order as produced by tileWorldRect:
// increasing tile X index, then increasing tile Y index.
Tiles []TileDrawPlan
}
// TileDrawPlan describes how to draw one torus tile contribution.
type TileDrawPlan struct {
Tile WorldTile
// Clip rect on the expanded canvas in pixel coordinates.
// It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH).
ClipX int
ClipY int
ClipW int
ClipH int
// Candidates are unique per tile (deduped by ID).
Candidates []MapItem
}
// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span
// for the given fixed-point zoom. The conversion is truncating (floor).
func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int {
// spanWorldFp can be negative in some internal cases, but for clip computations
// we always pass non-negative spans.
return (spanWorldFp * zoomFp) / (SCALE * SCALE)
}
// buildRenderPlanStageA builds a full expanded-canvas redraw plan (Stage A).
//
// It assumes the world grid is already built (IndexOnViewportChange called).
// The plan contains per-tile clip rectangles and per-tile candidate lists
// from the spatial index.
func (w *World) buildRenderPlanStageA(params RenderParams) (RenderPlan, error) {
if err := params.Validate(); err != nil {
return RenderPlan{}, err
}
zoomFp, err := params.CameraZoomFp()
if err != nil {
return RenderPlan{}, err
}
worldRect, err := params.ExpandedCanvasWorldRect()
if err != nil {
return RenderPlan{}, err
}
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
var tiles []WorldTile
if allowWrap {
tiles = tileWorldRect(worldRect, w.W, w.H)
} else {
tiles = tileWorldRectNoWrap(worldRect, w.W, w.H)
}
// Query candidates per tile.
batches, err := w.collectCandidatesForTiles(tiles)
if err != nil {
return RenderPlan{}, err
}
planTiles := make([]TileDrawPlan, 0, len(batches))
for _, batch := range batches {
tile := batch.Tile
// Convert the tile's canonical rect + offsets into the unwrapped segment.
segMinX := tile.Rect.minX + tile.OffsetX
segMaxX := tile.Rect.maxX + tile.OffsetX
segMinY := tile.Rect.minY + tile.OffsetY
segMaxY := tile.Rect.maxY + tile.OffsetY
// Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY.
clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp)
clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp)
clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp)
clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp)
clipW := clipX2 - clipX
clipH := clipY2 - clipY
planTiles = append(planTiles, TileDrawPlan{
Tile: tile,
ClipX: clipX,
ClipY: clipY,
ClipW: clipW,
ClipH: clipH,
Candidates: batch.Items,
})
}
return RenderPlan{
CanvasWidthPx: params.CanvasWidthPx(),
CanvasHeightPx: params.CanvasHeightPx(),
ZoomFp: zoomFp,
WorldRect: worldRect,
Tiles: planTiles,
}, nil
}
-53
View File
@@ -1,53 +0,0 @@
package world
import (
"image/color"
"testing"
)
func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) {
w := NewWorld(600, 600)
// Make the index/grid available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Populate with enough objects to create duplicates across cells.
// Circles and lines create bbox indexing (more duplicates).
for i := 0; i < 2000; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := 0; i < 1200; i++ {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0)
}
for i := 0; i < 1200; i++ {
x1 := float64((i*3 + 10) % 600)
y1 := float64((i*5 + 20) % 600)
x2 := float64((i*7 + 400) % 600)
y2 := float64((i*11 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := w.buildRenderPlanStageA(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
}
}
-135
View File
@@ -1,135 +0,0 @@
package world
import "math"
// drawPointsFromPlan keeps backward compatibility for older tests/helpers.
func drawPointsFromPlan(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool) {
// Default world sizes are unknown here, so this wrapper is no longer suitable for wrap-aware points.
// Keep it for historical call sites only if they pass through Render().
// Prefer calling drawPointsFromPlanWithRadius with world sizes.
drawPointsFromPlanWithRadius(drawer, plan, 0, 0, DefaultRenderStyle().PointRadiusPx, allowWrap)
}
// drawPointsFromPlanWithRadius executes a points-only draw from an already built render plan,
// using the provided screen-space radius. If worldW/worldH are zero, wrap copies are disabled.
func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, radiusPx float64, allowWrap bool) {
// Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed.
rPxInt := int(math.Ceil(radiusPx))
if rPxInt < 0 {
rPxInt = 0
}
rWorldFp := 0
if rPxInt > 0 {
rWorldFp = PixelSpanToWorldFixed(rPxInt, plan.ZoomFp)
}
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
points := make([]Point, 0, len(td.Candidates))
for _, it := range td.Candidates {
p, ok := it.(Point)
if !ok {
continue
}
points = append(points, p)
}
if len(points) == 0 {
continue
}
type pointCopy struct {
p Point
dx int
dy int
}
copiesToDraw := make([]pointCopy, 0, len(points))
for _, p := range points {
var shifts []wrapShift
if allowWrap {
shifts = pointWrapShifts(p, rWorldFp, worldW, worldH)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, pc := range copiesToDraw {
p := pc.p
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+pc.dx)-plan.WorldRect.minX, plan.ZoomFp)
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+pc.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddPoint(float64(px), float64(py), radiusPx)
}
drawer.Fill()
drawer.Restore()
}
}
func pointWrapShifts(p Point, rWorldFp, worldW, worldH int) []wrapShift {
// If world sizes are unknown, do not generate wrap copies.
if worldW <= 0 || worldH <= 0 {
return []wrapShift{{dx: 0, dy: 0}}
}
xShifts := []int{0}
yShifts := []int{0}
if p.X+rWorldFp >= worldW {
xShifts = append(xShifts, -worldW)
}
if p.X-rWorldFp < 0 {
xShifts = append(xShifts, worldW)
}
if p.Y+rWorldFp >= worldH {
yShifts = append(yShifts, -worldH)
}
if p.Y-rWorldFp < 0 {
yShifts = append(yShifts, worldH)
}
out := make([]wrapShift, 0, len(xShifts)*len(yShifts))
for _, dx := range xShifts {
for _, dy := range yShifts {
out = append(out, wrapShift{dx: dx, dy: dy})
}
}
return out
}
func pointCopyIntersectsTile(p Point, rWorldFp, dx, dy int, tile WorldTile) bool {
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
px := p.X + tile.OffsetX + dx
py := p.Y + tile.OffsetY + dy
minX := px - rWorldFp
maxX := px + rWorldFp
minY := py - rWorldFp
maxY := py + rWorldFp
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}
-163
View File
@@ -1,163 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
t.Parallel()
// World is 10x10 world units => 10000x10000 fixed.
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Place a point near the origin so that expanded canvas (larger than world)
// will require torus repetition and the point will appear in multiple tiles.
id, err := w.AddPoint(1.0, 1.0) // (1000,1000)
require.NoError(t, err)
// Index only this object.
w.indexObject(w.objects[id])
// Choose viewport such that viewport==world in pixels at zoom=1:
// - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px.
// - world width=10 units => 10 px.
// Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world.
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan, 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)
}
}
func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Add only a line, no points.
id, err := w.AddLine(2, 2, 8, 2)
require.NoError(t, err)
w.indexObject(w.objects[id])
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan, true)
// No points => no drawing commands at all.
require.Empty(t, d.Commands())
}
func TestWorldRender_PointsOnlyStageA(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(5, 5)
require.NoError(t, err)
// Build index. In real UI it happens via IndexOnViewportChange.
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
// At least one point draw should happen.
require.Contains(t, d.CommandNames(), "AddPoint")
}
-99
View File
@@ -1,99 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
t.Parallel()
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Style: func() *RenderStyle {
s := DefaultRenderStyle()
s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1
return &s
}(),
},
}
type tc struct {
name string
x, y float64
wantCenters [][2]float64
}
tests := []tc{
{
name: "bottom boundary wraps to top",
x: 5,
y: 9,
wantCenters: [][2]float64{{5, 9}, {5, -1}},
},
{
name: "right boundary wraps to left",
x: 9,
y: 5,
wantCenters: [][2]float64{{9, 5}, {-1, 5}},
},
{
name: "corner wraps to three extra copies",
x: 9,
y: 9,
wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}},
},
{
name: "no wrap inside",
x: 5,
y: 5,
wantCenters: [][2]float64{{5, 5}},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(tt.x, tt.y)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
style := DefaultRenderStyle()
style.PointRadiusPx = 2.0
applyPointStyle(d, style)
drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, 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)
})
}
}
-92
View File
@@ -1,92 +0,0 @@
package world
import (
"errors"
)
var (
errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first")
)
// TileCandidates binds one torus tile to the list of unique grid candidates
// that intersect the tile rectangle.
//
// Items are not guaranteed to be truly visible; the grid is a coarse spatial index.
// Exact visibility tests are performed later in the renderer pipeline.
type TileCandidates struct {
Tile WorldTile
Items []MapItem
}
// collectCandidatesForTiles queries the world grid for each tile rectangle
// and returns per-tile unique candidate lists.
//
// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by
// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed.
func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) {
if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 {
return nil, errGridNotBuilt
}
out := make([]TileCandidates, 0, len(tiles))
for _, tile := range tiles {
items := w.collectCandidatesForTile(tile.Rect)
out = append(out, TileCandidates{
Tile: tile,
Items: items,
})
}
return out, nil
}
// collectCandidatesForTile returns a unique set of grid candidates for a single
// canonical-world tile rectangle [0..W) x [0..H).
//
// The rectangle must be half-open and expressed in fixed-point world coordinates.
func (w *World) collectCandidatesForTile(r Rect) []MapItem {
// Empty rect => no candidates.
if r.maxX <= r.minX || r.maxY <= r.minY {
return nil
}
// Map rect to cell ranges using the same half-open conventions as indexing:
// the last included cell is computed from (max-1).
colStart := w.worldToCellX(r.minX)
colEnd := w.worldToCellX(r.maxX - 1)
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
// Start a new epoch for this tile dedupe.
w.candSeenResetIfOverflow()
// Reuse result buffer.
out := w.scratchCandidates[:0]
for row := rowStart; row <= rowEnd; row++ {
for col := colStart; col <= colEnd; col++ {
cell := w.grid[row][col]
for _, item := range cell {
id := item.ID()
if w.candSeenMark(id) {
continue
}
out = append(out, item)
}
}
}
// Store back the reusable buffer (keep capacity).
w.scratchCandidates = out[:0]
// IMPORTANT:
// We must return a stable slice to the caller (plan stores it).
// Returning `out` directly would be overwritten on the next tile.
//
// So: copy out into a freshly allocated slice OR into a plan-level scratch pool.
// For Step 1 we keep correctness: allocate exactly once per tile.
// Step 3 will remove this allocation by making plan own a pooled backing store.
res := make([]MapItem, len(out))
copy(res, out)
return res
}
-44
View File
@@ -1,44 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(1, 1)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
_, err = w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
require.Contains(t, names, "AddCircle")
require.Contains(t, names, "AddLine")
}
-71
View File
@@ -1,71 +0,0 @@
// Pseudo-code / toolkit-agnostic example.
// Goal: never render more than one frame concurrently, and always render the latest params.
//
// Это не “асинхронная работа в фоне”, а чистый паттерн управления вызовами Render.
// Он работает в любом UI, где у тебя есть loop и возможность запускать отрисовку
// (например, через requestAnimationFrame-аналог или таски).
//
// Как это сочетается с CoalesceUpdates
// - Если params.Options.Incremental.CoalesceUpdates == true, UI использует этот scheduler.
// - Если false, UI может пытаться рендерить каждое событие (но это обычно хуже).
//
// # Важный момент
//
// Это не делает рендер асинхронным в смысле “рендерить в фоне” — в реальном UI ты должен
// выполнять w.Render строго в UI thread. Я показал go только как “планировщик”
// (в твоём GUI заменишь на invokeOnMainThread/PostTask/RunOnUI).
package world
import "sync"
type RenderScheduler struct {
w *World
drawer PrimitiveDrawer
// Protects fields below.
mu sync.Mutex
inFlight bool
pending bool
latest RenderParams
}
// RequestRender stores the latest params and schedules rendering.
// If a render is already in progress, it coalesces (drops intermediate requests).
func (s *RenderScheduler) RequestRender(params RenderParams) {
s.mu.Lock()
s.latest = params
if s.inFlight {
s.pending = true
s.mu.Unlock()
return
}
s.inFlight = true
s.mu.Unlock()
// Schedule on the UI thread/event loop. Replace this with your toolkit method.
go s.runOnUIThread()
}
// runOnUIThread should execute on the UI thread.
// Replace the body with actual UI scheduling primitives.
func (s *RenderScheduler) runOnUIThread() {
for {
s.mu.Lock()
params := s.latest
s.mu.Unlock()
s.w.ClampRenderParamsNoWrap(&params)
_ = s.w.Render(s.drawer, params) // handle error in real code
s.mu.Lock()
if !s.pending {
s.inFlight = false
s.mu.Unlock()
return
}
// There was a newer request while we were rendering. Loop and render latest.
s.pending = false
s.mu.Unlock()
}
}
-44
View File
@@ -1,44 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddPoint(1, 1)
require.NoError(t, err)
_, err = w.AddCircle(2, 2, 1)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
plan, err := w.buildRenderPlanStageA(params)
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan, true)
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
require.Contains(t, names, "AddCircle")
}
-54
View File
@@ -1,54 +0,0 @@
package world
import "image/color"
// RenderStyle describes visual parameters for renderer passes.
// It is intentionally screen-space oriented (pixels), since the renderer
// already projects world coordinates into canvas pixels.
type RenderStyle struct {
// PointRadiusPx is the screen-space radius for Point markers.
PointRadiusPx float64
// PointFill is the fill color for points.
PointFill color.Color
// CircleFill is the fill color for circles.
CircleFill color.Color
// LineStroke is the stroke color for lines.
LineStroke color.Color
// LineWidthPx is the stroke width for lines.
LineWidthPx float64
// LineDash is the dash pattern for lines. Empty => solid.
LineDash []float64
// LineDashOffset is the dash phase for lines.
LineDashOffset float64
}
// DefaultRenderStyle returns the default style used when UI does not provide one.
// Defaults are intentionally simple and stable for testing.
func DefaultRenderStyle() RenderStyle {
return RenderStyle{
PointRadiusPx: 2.0,
PointFill: color.White,
CircleFill: color.White,
LineStroke: color.White,
LineWidthPx: 2.0,
LineDash: nil,
LineDashOffset: 0,
}
}
func DefaultIncrementalPolicy() IncrementalPolicy {
return IncrementalPolicy{
CoalesceUpdates: false,
AllowShiftOnly: false,
RenderBudgetMs: 0,
MaxCatchUpAreaPx: 0,
}
}
@@ -1,205 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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")
}
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)
}
}
}
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
}
-19
View File
@@ -1,19 +0,0 @@
package world
// applyPointStyle configures drawer state for point rendering.
func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetFillColor(style.PointFill)
}
// applyCircleStyle configures drawer state for circle rendering.
func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetFillColor(style.CircleFill)
}
// applyLineStyle configures drawer state for line rendering.
func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) {
drawer.SetStrokeColor(style.LineStroke)
drawer.SetLineWidth(style.LineWidthPx)
drawer.SetDash(style.LineDash...)
drawer.SetDashOffset(style.LineDashOffset)
}
File diff suppressed because it is too large Load Diff
-199
View File
@@ -1,199 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// rendererTestEnv groups the common mutable inputs used by renderer tests.
// The environment stores independent horizontal and vertical margins because
// the expanded canvas geometry is derived separately on each axis.
type rendererTestEnv struct {
world *World
drawer *fakePrimitiveDrawer
// Viewport origin and size in canvas pixel coordinates.
viewportX int
viewportY int
viewportW int
viewportH int
// Independent margins around the viewport in canvas pixels.
marginXPx int
marginYPx int
// Final expanded canvas size in pixels.
// In the default setup:
// canvasW = viewportW + 2*marginXPx
// canvasH = viewportH + 2*marginYPx
canvasW int
canvasH int
// Camera center in fixed-point world coordinates.
cameraX int
cameraY int
// Camera zoom in fixed-point representation, if needed by renderer internals.
zoomFp int
}
// newRendererTestEnv returns a baseline renderer test environment.
// The default margins are derived independently from viewport width and height.
func newRendererTestEnv() *rendererTestEnv {
viewportW := 100
viewportH := 80
marginXPx := viewportW / 4
marginYPx := viewportH / 4
return &rendererTestEnv{
world: NewWorld(10, 10),
drawer: &fakePrimitiveDrawer{},
viewportX: marginXPx,
viewportY: marginYPx,
viewportW: viewportW,
viewportH: viewportH,
marginXPx: marginXPx,
marginYPx: marginYPx,
canvasW: viewportW + 2*marginXPx,
canvasH: viewportH + 2*marginYPx,
cameraX: 5 * SCALE,
cameraY: 5 * SCALE,
zoomFp: SCALE,
}
}
// setViewport resets viewport-dependent fields and recomputes margins
// using the default test formula:
//
// marginXPx = viewportW / 4
// marginYPx = viewportH / 4
func (env *rendererTestEnv) setViewport(viewportW, viewportH int) {
env.viewportW = viewportW
env.viewportH = viewportH
env.marginXPx = viewportW / 4
env.marginYPx = viewportH / 4
env.viewportX = env.marginXPx
env.viewportY = env.marginYPx
env.canvasW = env.viewportW + 2*env.marginXPx
env.canvasH = env.viewportH + 2*env.marginYPx
}
// setViewportAndMargins overrides viewport and margins explicitly.
// This is useful for edge cases where the expanded canvas geometry
// must be controlled exactly.
func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) {
env.viewportW = viewportW
env.viewportH = viewportH
env.marginXPx = marginXPx
env.marginYPx = marginYPx
env.viewportX = env.marginXPx
env.viewportY = env.marginYPx
env.canvasW = env.viewportW + 2*env.marginXPx
env.canvasH = env.viewportH + 2*env.marginYPx
}
// viewportRect returns the viewport rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) {
return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH)
}
// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) {
return 0, 0, float64(env.canvasW), float64(env.canvasH)
}
// worldMustAddPoint adds a point to the test world and fails the test on error.
func worldMustAddPoint(t *testing.T, w *World, x, y float64) {
t.Helper()
_, err := w.AddPoint(x, y)
require.NoError(t, err)
}
// worldMustAddCircle adds a circle to the test world and fails the test on error.
func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) {
t.Helper()
_, err := w.AddCircle(x, y, r)
require.NoError(t, err)
}
// worldMustAddLine adds a line to the test world and fails the test on error.
func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) {
t.Helper()
_, err := w.AddLine(x1, y1, x2, y2)
require.NoError(t, err)
}
// requireNoDrawerCommands asserts that the renderer produced no drawing commands.
func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) {
t.Helper()
require.Empty(t, d.Commands())
}
// requireStrokeCommandAt returns a command and asserts that it is Stroke.
func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "Stroke")
return cmd
}
// requireFillCommandAt returns a command and asserts that it is Fill.
func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "Fill")
return cmd
}
// requireAddPointCommandAt returns a command and asserts that it is AddPoint.
func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddPoint")
return cmd
}
// requireAddLineCommandAt returns a command and asserts that it is AddLine.
func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddLine")
return cmd
}
// requireAddCircleCommandAt returns a command and asserts that it is AddCircle.
func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmd := requireDrawerCommandAt(t, d, index)
requireCommandName(t, cmd, "AddCircle")
return cmd
}
// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect.
func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) {
t.Helper()
requireCommandClipRects(t, cmd, fakeClipRect{
X: x,
Y: y,
W: w,
H: h,
})
}
-219
View File
@@ -1,219 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
// rendererTestCase is a generic table-driven renderer test scaffold.
// Replace invoke with the real renderer call once the renderer exists.
type rendererTestCase struct {
name string
// setup prepares the world and optional environment overrides.
setup func(t *testing.T, env *rendererTestEnv)
// invoke calls the renderer under test.
invoke func(t *testing.T, env *rendererTestEnv)
// verify checks the produced fake drawer log.
verify func(t *testing.T, env *rendererTestEnv)
}
func runRendererTestCases(t *testing.T, cases []rendererTestCase) {
t.Helper()
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newRendererTestEnv()
if tc.setup != nil {
tc.setup(t, env)
}
require.NotNil(t, tc.invoke, "renderer test case must define invoke")
require.NotNil(t, tc.verify, "renderer test case must define verify")
tc.invoke(t, env)
tc.verify(t, env)
})
}
}
// TestRenderer_Template_PointCases is a scaffold for future point renderer tests.
func TestRenderer_Template_PointCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "point fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddPoint(t, env.world, 5, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point visible only in horizontal margin copy",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddPoint(t, env.world, 0.1, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point visible only in vertical margin copy",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddPoint(t, env.world, 5, 0.1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "point duplicated across torus corner with independent margins",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewportAndMargins(120, 60, 30, 10)
worldMustAddPoint(t, env.world, 0.1, 0.1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
// TestRenderer_Template_LineCases is a scaffold for future line renderer tests.
func TestRenderer_Template_LineCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "line fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddLine(t, env.world, 2, 2, 8, 2)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line wrap copy across x edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddLine(t, env.world, 9, 5, 1, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line wrap copy across y edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddLine(t, env.world, 5, 9, 5, 1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "line tie case uses deterministic wrapped representation",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddLine(t, env.world, 1, 5, 6, 5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests.
func TestRenderer_Template_CircleCases(t *testing.T) {
t.Parallel()
runRendererTestCases(t, []rendererTestCase{
{
name: "circle fully inside viewport",
setup: func(t *testing.T, env *rendererTestEnv) {
worldMustAddCircle(t, env.world, 5, 5, 1)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across horizontal edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(160, 40)
worldMustAddCircle(t, env.world, 0.2, 5, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across vertical edge",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewport(40, 160)
worldMustAddCircle(t, env.world, 5, 0.2, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
{
name: "circle duplicated across corner with asymmetric margins",
setup: func(t *testing.T, env *rendererTestEnv) {
env.setViewportAndMargins(120, 60, 30, 10)
worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5)
},
invoke: func(t *testing.T, env *rendererTestEnv) {
t.Skip("replace with actual renderer call")
},
verify: func(t *testing.T, env *rendererTestEnv) {
requireNoDrawerCommands(t, env.drawer)
},
},
})
}
@@ -1,90 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
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
}
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)
}
+463
View File
@@ -1,6 +1,7 @@
package world
import (
"image"
"image/color"
"sync"
)
@@ -29,6 +30,10 @@ var (
transparentColor color.Color = &color.RGBA{A: 0}
)
// TransparentFill returns a reusable fully transparent color value.
//
// It is intended for callers that want to explicitly disable fill while still
// setting a non-nil FillColor override.
func TransparentFill() color.Color { return transparentColor }
// Style is a fully resolved style used by the renderer.
@@ -232,3 +237,461 @@ func (t *StyleTable) Count() int {
defer t.mu.RUnlock()
return len(t.styles)
}
// BackgroundTileMode defines how the background image is tiled.
type BackgroundTileMode uint8
const (
BackgroundTileNone BackgroundTileMode = iota
BackgroundTileRepeat
)
// BackgroundAnchorMode defines whether the background image scrolls with the world or stays fixed to viewport.
type BackgroundAnchorMode uint8
const (
BackgroundAnchorWorld BackgroundAnchorMode = iota
BackgroundAnchorViewport
)
// BackgroundScaleMode defines how the background image is scaled.
// (Step 1: defined for API completeness; used later when rendering background image.)
type BackgroundScaleMode uint8
const (
BackgroundScaleNone BackgroundScaleMode = iota
BackgroundScaleFit
BackgroundScaleFill
)
// StyleTheme describes a cohesive style set (theme) for rendering.
// Step 1: we store it in World and use it for background and default base styles.
// Step 2+: theme-relative overrides and background image drawing.
type StyleTheme interface {
ID() string
Name() string
BackgroundColor() color.Color
BackgroundImage() image.Image
BackgroundTileMode() BackgroundTileMode
BackgroundScaleMode() BackgroundScaleMode
BackgroundAnchorMode() BackgroundAnchorMode
PointStyle() Style
LineStyle() Style
CircleStyle() Style
// Class overrides (relative to base kind style).
// Return (override, true) when class is supported; (zero, false) means "no override".
PointClassOverride(class PointClassID) (StyleOverride, bool)
LineClassOverride(class LineClassID) (StyleOverride, bool)
CircleClassOverride(class CircleClassID) (StyleOverride, bool)
}
// DefaultTheme is a conservative theme matching built-in default styles.
type DefaultTheme struct{}
func (DefaultTheme) ID() string { return "default" }
func (DefaultTheme) Name() string { return "Default" }
func (DefaultTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (DefaultTheme) BackgroundImage() image.Image { return nil }
func (DefaultTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (DefaultTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (DefaultTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (DefaultTheme) PointStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultPoint)
return s
}
func (DefaultTheme) LineStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultLine)
return s
}
func (DefaultTheme) CircleStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultCircle)
return s
}
func (DefaultTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// This file provides two sample themes for demos and UI integration:
// LightTheme uses only background color, while DarkTheme also carries a
// prebuilt tiled texture image.
var (
// ThemeLight is the shared light theme instance used by the client package.
ThemeLight = &LightTheme{}
// ThemeDark is the shared dark theme instance used by the client package.
ThemeDark = NewDarkTheme()
)
// -----------------------------
// Helpers
// -----------------------------
// cRGBA constructs an sRGB color from 8-bit RGBA channels.
func cRGBA(r, g, b, a uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: a} }
// -----------------------------
// Light Theme (color only)
// -----------------------------
// LightTheme is a soft high-contrast theme intended for bright backgrounds.
type LightTheme struct{}
func (LightTheme) ID() string { return "theme.light.v1" }
func (LightTheme) Name() string { return "Light (Soft)" }
func (LightTheme) BackgroundColor() color.Color { return cRGBA(244, 246, 248, 255) } // #F4F6F8
func (LightTheme) BackgroundImage() image.Image { return nil }
func (LightTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (LightTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (LightTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
// Base styles per primitive kind (full Style, not override).
func (LightTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(32, 161, 145, 255), // soft teal
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (LightTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(70, 108, 196, 220), // soft blue
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (LightTheme) CircleStyle() Style {
return Style{
FillColor: cRGBA(133, 110, 201, 60), // soft purple with low alpha
StrokeColor: cRGBA(133, 110, 201, 200), // soft purple
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
// muted gray-blue
return StyleOverride{
FillColor: cRGBA(120, 135, 160, 230),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
// soft green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 240),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
// soft orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 240),
PointRadiusPx: new(3.5),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
// dashed neutral line
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(100, 110, 125, 200),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassHome:
// teal-ish, a bit stronger stroke
return StyleOverride{
FillColor: cRGBA(32, 161, 145, 50),
StrokeColor: cRGBA(32, 161, 145, 210),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
// blue
return StyleOverride{
FillColor: cRGBA(70, 108, 196, 45),
StrokeColor: cRGBA(70, 108, 196, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOccupied:
// orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 50),
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFree:
// green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 45),
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// -----------------------------
// Dark Theme (color + tiled image)
// -----------------------------
// DarkTheme is a dark theme with an optional reusable background tile.
type DarkTheme struct {
bg image.Image
}
// NewDarkTheme constructs a DarkTheme with its immutable texture tile prepared.
func NewDarkTheme() *DarkTheme {
return &DarkTheme{bg: makeDarkBackgroundTile(96, 96)}
}
func (*DarkTheme) ID() string { return "theme.dark.v1" }
func (*DarkTheme) Name() string { return "Dark (Soft + Texture)" }
func (*DarkTheme) BackgroundColor() color.Color { return cRGBA(30, 32, 38, 255) } // #1E2026
func (t *DarkTheme) BackgroundImage() image.Image {
return nil
// This image is immutable after creation.
// return t.bg
}
func (*DarkTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (*DarkTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (*DarkTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
// Base styles for dark theme.
func (*DarkTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(120, 214, 198, 255), // brighter teal for dark bg
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (*DarkTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(155, 175, 235, 220), // soft bluish
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (*DarkTheme) CircleStyle() Style {
return Style{
FillColor: cRGBA(186, 160, 255, 55), // soft lavender, low alpha
StrokeColor: cRGBA(186, 160, 255, 200), // soft lavender
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
return StyleOverride{
FillColor: cRGBA(150, 160, 175, 230),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 245),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 245),
PointRadiusPx: new(3.5),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(132, 219, 162, 220),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(245, 178, 120, 220),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(170, 175, 190, 200),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassHome:
return StyleOverride{
FillColor: cRGBA(120, 214, 198, 50),
StrokeColor: cRGBA(120, 214, 198, 210),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
return StyleOverride{
FillColor: cRGBA(155, 175, 235, 45),
StrokeColor: cRGBA(155, 175, 235, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOccupied:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 45),
StrokeColor: cRGBA(245, 178, 120, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFree:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 45),
StrokeColor: cRGBA(132, 219, 162, 220),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// makeDarkBackgroundTile creates a subtle, low-contrast texture tile.
// It is intentionally simple: a faint grid + a few diagonal accents.
// The tile is meant to be repeated.
func makeDarkBackgroundTile(w, h int) image.Image {
if w <= 0 || h <= 0 {
return nil
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
// Base is transparent; background color is drawn separately.
// We draw subtle strokes with low alpha.
grid := color.RGBA{R: 255, G: 255, B: 255, A: 12} // very faint
diag := color.RGBA{R: 255, G: 255, B: 255, A: 18} // slightly stronger
dots := color.RGBA{R: 255, G: 255, B: 255, A: 10} // faint dots
// Grid spacing (pixels).
const step = 12
// Vertical grid lines.
for x := 0; x < w; x += step {
for y := 0; y < h; y++ {
img.SetRGBA(x, y, grid)
}
}
// Horizontal grid lines.
for y := 0; y < h; y += step {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, grid)
}
}
// Diagonal accents (sparse).
for x := 0; x < w; x += step * 2 {
for i := 0; i < step && x+i < w && i < h; i++ {
img.SetRGBA(x+i, i, diag)
}
}
// Small dot pattern.
for y := step / 2; y < h; y += step {
for x := step / 2; x < w; x += step {
img.SetRGBA(x, y, dots)
}
}
return img
}
-84
View File
@@ -1,84 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
type cacheTheme struct{}
func (cacheTheme) ID() string { return "cache" }
func (cacheTheme) Name() string { return "cache" }
func (cacheTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (cacheTheme) BackgroundImage() image.Image { return nil }
func (cacheTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (cacheTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (cacheTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (cacheTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
func (cacheTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
func (cacheTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (cacheTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (cacheTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
type cacheTheme2 struct{ cacheTheme }
func (cacheTheme2) ID() string { return "cache2" }
func (cacheTheme2) Name() string { return "cache2" }
func (cacheTheme2) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 200, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 3}
}
func (cacheTheme2) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(cacheTheme{})
before := w.styles.Count()
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id1, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
id2, err := w.AddCircle(6, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id1].(Circle)
c2 := w.objects[id2].(Circle)
require.Equal(t, c1.StyleID, c2.StyleID, "same override must reuse derived style ID")
afterAdd := w.styles.Count()
require.Equal(t, before+1, afterAdd, "only one derived style should be added for identical overrides")
// Change theme: derived cache is cleared and new base IDs are created; override must still be applied,
// and both objects should again share one derived style for the new base.
w.SetTheme(cacheTheme2{})
c1b := w.objects[id1].(Circle)
c2b := w.objects[id2].(Circle)
require.Equal(t, c1b.StyleID, c2b.StyleID)
afterTheme := w.styles.Count()
// Theme change creates 3 new theme default styles + 1 new derived for the override.
require.GreaterOrEqual(t, afterTheme, afterAdd+4)
}
@@ -1,96 +0,0 @@
package world
import (
"hash"
"hash/fnv"
"image/color"
"math"
)
// hashU64 writes v to the hash in little-endian form.
// We keep it manual to avoid extra allocations and dependencies.
func hashU64(h hash.Hash64, v uint64) {
var b [8]byte
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
b[4] = byte(v >> 32)
b[5] = byte(v >> 40)
b[6] = byte(v >> 48)
b[7] = byte(v >> 56)
_, _ = h.Write(b[:])
}
func hashBool(h hash.Hash64, v bool) {
if v {
hashU64(h, 1)
} else {
hashU64(h, 0)
}
}
func hashColor(h hash.Hash64, c color.Color) {
if c == nil {
hashU64(h, 0)
return
}
r, g, b, a := c.RGBA()
hashU64(h, uint64(r))
hashU64(h, uint64(g))
hashU64(h, uint64(b))
hashU64(h, uint64(a))
}
// fingerprint returns a stable hash of the override content.
//
// Notes on semantics:
// - FillColor / StrokeColor: nil means "unset" (do not override). Transparent override is represented
// by a non-nil color with alpha=0.
// - Pointer fields (*float64, *[]float64) encode presence via nil/non-nil.
// - StrokeDashes: nil pointer means "unset"; non-nil pointer to nil slice means "set to nil".
func (o StyleOverride) fingerprint() uint64 {
h := fnv.New64a() // returns hash.Hash64
// FillColor / StrokeColor
hashBool(h, o.FillColor != nil)
hashColor(h, o.FillColor)
hashBool(h, o.StrokeColor != nil)
hashColor(h, o.StrokeColor)
// StrokeWidthPx
hashBool(h, o.StrokeWidthPx != nil)
if o.StrokeWidthPx != nil {
hashU64(h, math.Float64bits(*o.StrokeWidthPx))
}
// StrokeDashes
hashBool(h, o.StrokeDashes != nil)
if o.StrokeDashes != nil {
ds := *o.StrokeDashes
if ds == nil {
// Explicitly set to nil slice
hashU64(h, 0xffffffffffffffff)
} else {
hashU64(h, uint64(len(ds)))
for _, v := range ds {
hashU64(h, math.Float64bits(v))
}
}
}
// StrokeDashOffset
hashBool(h, o.StrokeDashOffset != nil)
if o.StrokeDashOffset != nil {
hashU64(h, math.Float64bits(*o.StrokeDashOffset))
}
// PointRadiusPx
hashBool(h, o.PointRadiusPx != nil)
if o.PointRadiusPx != nil {
hashU64(h, math.Float64bits(*o.PointRadiusPx))
}
return h.Sum64()
}
-29
View File
@@ -1,29 +0,0 @@
package world
func mergeOverrides(classOv, userOv StyleOverride) StyleOverride {
out := classOv
// Colors: nil means "unset"
if userOv.FillColor != nil {
out.FillColor = userOv.FillColor
}
if userOv.StrokeColor != nil {
out.StrokeColor = userOv.StrokeColor
}
// Pointers: nil means "unset"
if userOv.StrokeWidthPx != nil {
out.StrokeWidthPx = userOv.StrokeWidthPx
}
if userOv.StrokeDashes != nil {
out.StrokeDashes = userOv.StrokeDashes
}
if userOv.StrokeDashOffset != nil {
out.StrokeDashOffset = userOv.StrokeDashOffset
}
if userOv.PointRadiusPx != nil {
out.PointRadiusPx = userOv.PointRadiusPx
}
return out
}
+476 -2
View File
@@ -1,12 +1,13 @@
package world
import (
"github.com/stretchr/testify/require"
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
// TestStyleOverrideApply_OverridesOnlyProvidedFields verifies style Override Apply Overrides Only Provided Fields.
func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
t.Parallel()
@@ -38,6 +39,7 @@ func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
require.Equal(t, 7.0, out.PointRadiusPx)
}
// TestStyleTable_DefaultsExistAndAreStable verifies style Table Defaults Exist And Are Stable.
func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
t.Parallel()
@@ -53,6 +55,7 @@ func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
require.True(t, ok)
}
// TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices verifies style Table Add Derived Stores Resolved Style And Copies Slices.
func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T) {
t.Parallel()
@@ -83,6 +86,7 @@ func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T)
require.Equal(t, []float64{10, 5}, got3.StrokeDashes)
}
// TestDefaultPriorities_AreOrderedAndStepped verifies default Priorities Are Ordered And Stepped.
func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
t.Parallel()
@@ -93,3 +97,473 @@ func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
require.Less(t, DefaultPriorityLine, DefaultPriorityCircle)
require.Less(t, DefaultPriorityCircle, DefaultPriorityPoint)
}
type cacheTheme struct{}
func (cacheTheme) ID() string { return "cache" }
func (cacheTheme) Name() string { return "cache" }
func (cacheTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (cacheTheme) BackgroundImage() image.Image { return nil }
func (cacheTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (cacheTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (cacheTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (cacheTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
func (cacheTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
func (cacheTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (cacheTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (cacheTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
type cacheTheme2 struct{ cacheTheme }
func (cacheTheme2) ID() string { return "cache2" }
func (cacheTheme2) Name() string { return "cache2" }
func (cacheTheme2) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 200, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 3}
}
func (cacheTheme2) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes verifies derived Style Cache Reuses Derived Styles Across Objects And Themes.
func TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(cacheTheme{})
before := w.styles.Count()
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id1, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
id2, err := w.AddCircle(6, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id1].(Circle)
c2 := w.objects[id2].(Circle)
require.Equal(t, c1.StyleID, c2.StyleID, "same override must reuse derived style ID")
afterAdd := w.styles.Count()
require.Equal(t, before+1, afterAdd, "only one derived style should be added for identical overrides")
// Change theme: derived cache is cleared and new base IDs are created; override must still be applied,
// and both objects should again share one derived style for the new base.
w.SetTheme(cacheTheme2{})
c1b := w.objects[id1].(Circle)
c2b := w.objects[id2].(Circle)
require.Equal(t, c1b.StyleID, c2b.StyleID)
afterTheme := w.styles.Count()
// Theme change creates 3 new theme default styles + 1 new derived for the override.
require.GreaterOrEqual(t, afterTheme, afterAdd+4)
}
type testTheme struct{}
func (testTheme) ID() string { return "t1" }
func (testTheme) Name() string { return "Theme1" }
func (testTheme) BackgroundColor() color.Color { return color.RGBA{R: 1, G: 2, B: 3, A: 255} }
func (testTheme) BackgroundImage() image.Image { return nil }
func (testTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (testTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (testTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (testTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (testTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) PointStyle() Style {
return Style{
FillColor: color.RGBA{R: 9, A: 255},
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 4,
}
}
func (testTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: color.RGBA{G: 9, A: 255},
StrokeWidthPx: 3,
StrokeDashes: []float64{2, 2},
StrokeDashOffset: 1,
PointRadiusPx: 0,
}
}
func (testTheme) CircleStyle() Style {
return Style{
FillColor: color.RGBA{B: 9, A: 255},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: 2,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
}
// TestWorldSetTheme_MaterializesThemeDefaultStyles verifies world Set Theme Materializes Theme Default Styles.
func TestWorldSetTheme_MaterializesThemeDefaultStyles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Built-ins should remain stable (1/2/3).
require.Equal(t, StyleIDDefaultLine, StyleID(1))
require.Equal(t, StyleIDDefaultCircle, StyleID(2))
require.Equal(t, StyleIDDefaultPoint, StyleID(3))
// Set a custom theme.
w.SetTheme(testTheme{})
// Theme defaults should NOT be built-in IDs anymore.
require.NotEqual(t, StyleIDDefaultLine, w.themeDefaultLineStyleID)
require.NotEqual(t, StyleIDDefaultCircle, w.themeDefaultCircleStyleID)
require.NotEqual(t, StyleIDDefaultPoint, w.themeDefaultPointStyleID)
ls, ok := w.styles.Get(w.themeDefaultLineStyleID)
require.True(t, ok)
require.Equal(t, 3.0, ls.StrokeWidthPx)
require.Equal(t, []float64{2, 2}, ls.StrokeDashes)
require.Equal(t, 1.0, ls.StrokeDashOffset)
cs, ok := w.styles.Get(w.themeDefaultCircleStyleID)
require.True(t, ok)
require.Equal(t, 2.0, cs.StrokeWidthPx)
ps, ok := w.styles.Get(w.themeDefaultPointStyleID)
require.True(t, ok)
require.Equal(t, 4.0, ps.PointRadiusPx)
}
// TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride verifies render Uses Theme Background Color When No Option Override.
func TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
// Minimal index.
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// Should clear with theme background color via ClearAllTo(bg).
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
// TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor verifies render Options Background Color Overrides Theme Background Color.
func TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{R: 200, A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
const (
testPointClassExtended PointClassID = 1
testCircleClassExtended CircleClassID = 1
)
type classThemeA struct{}
func (classThemeA) ID() string { return "classA" }
func (classThemeA) Name() string { return "classA" }
func (classThemeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (classThemeA) BackgroundImage() image.Image { return nil }
func (classThemeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (classThemeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (classThemeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (classThemeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (classThemeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 6.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
func (classThemeA) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (classThemeA) CircleClassOverride(c CircleClassID) (StyleOverride, bool) {
if c == testCircleClassExtended {
w := 3.0
return StyleOverride{StrokeWidthPx: &w}, true
}
return StyleOverride{}, false
}
type classThemeB struct{ classThemeA }
func (classThemeB) ID() string { return "classB" }
func (classThemeB) Name() string { return "classB" }
func (classThemeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 3}
}
func (classThemeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 2}
}
func (classThemeB) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 9.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
// TestThemeClassOverride_AppliesAndUpdatesOnThemeChange verifies theme Class Override Applies And Updates On Theme Change.
func TestThemeClassOverride_AppliesAndUpdatesOnThemeChange(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
id, err := w.AddPoint(1, 1, PointWithClass(testPointClassExtended))
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 6.0, s1.PointRadiusPx)
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 9.0, s2.PointRadiusPx)
}
// TestThemeClassOverride_MergesWithUserOverride_UserWins verifies theme Class Override Merges With User Override User Wins.
func TestThemeClassOverride_MergesWithUserOverride_UserWins(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
// class would set point radius to 6, but user override sets it to 12.
rUser := 12.0
id, err := w.AddPoint(1, 1,
PointWithClass(testPointClassExtended),
PointWithStyleOverride(StyleOverride{PointRadiusPx: &rUser}),
)
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s1.PointRadiusPx)
// After theme change, class would set to 9, but user override must still win.
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s2.PointRadiusPx)
}
type themeA struct{}
func (themeA) ID() string { return "A" }
func (themeA) Name() string { return "A" }
func (themeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeA) BackgroundImage() image.Image { return nil }
func (themeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (themeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (themeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (themeA) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
type themeB struct{}
func (themeB) ID() string { return "B" }
func (themeB) Name() string { return "B" }
func (themeB) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeB) BackgroundImage() image.Image { return nil }
func (themeB) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeB) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeB) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 5}
}
func (themeB) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 99, A: 255}, StrokeWidthPx: 3}
}
func (themeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 4}
}
func (themeB) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
// TestThemeChange_UpdatesThemeDefaultStyleObjects verifies theme Change Updates Theme Default Style Objects.
func TestThemeChange_UpdatesThemeDefaultStyleObjects(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
id, err := w.AddPoint(1, 1) // default => theme-managed
require.NoError(t, err)
p := w.objects[id].(Point)
styleBefore := p.StyleID
w.SetTheme(themeB{})
p2 := w.objects[id].(Point)
styleAfter := p2.StyleID
require.NotEqual(t, styleBefore, styleAfter)
s, ok := w.styles.Get(styleAfter)
require.True(t, ok)
// From themeB point style
require.Equal(t, 5.0, s.PointRadiusPx)
}
// TestThemeChange_UpdatesThemeRelativeOverride verifies theme Change Updates Theme Relative Override.
func TestThemeChange_UpdatesThemeRelativeOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
s1, ok := w.styles.Get(c1.StyleID)
require.True(t, ok)
// Stroke overridden to white, fill from themeA (B=10).
require.Equal(t, uint32(0xffff), alphaOf(s1.StrokeColor))
require.Equal(t, u16FromU8(10), blueOf(s1.FillColor))
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
s2, ok := w.styles.Get(c2.StyleID)
require.True(t, ok)
// Still white stroke, but fill should now come from themeB (B=99).
require.Equal(t, uint32(0xffff), alphaOf(s2.StrokeColor))
require.Equal(t, u16FromU8(99), blueOf(s2.FillColor))
}
// TestThemeChange_DoesNotAffectFixedStyleID verifies theme Change Does Not Affect Fixed Style ID.
func TestThemeChange_DoesNotAffectFixedStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
sw := 2.0
fixed := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{R: 1, A: 255},
StrokeWidthPx: &sw,
})
id, err := w.AddCircle(5, 5, 2, CircleWithStyleID(fixed))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
require.Equal(t, fixed, c1.StyleID)
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
require.Equal(t, fixed, c2.StyleID)
}
func alphaOf(c color.Color) uint32 {
_, _, _, a := c.RGBA()
return a
}
func blueOf(c color.Color) uint32 {
_, _, b, _ := c.RGBA()
return b
}
// u16FromU8 converts an 8-bit channel value to the 16-bit value returned by color.Color.RGBA().
// The standard conversion is v * 257 (0x0101) so that 0xAB becomes 0xABAB.
func u16FromU8(v uint8) uint32 {
return uint32(v) * 257
}
-93
View File
@@ -1,93 +0,0 @@
package world
import (
"image"
"image/color"
)
// BackgroundTileMode defines how the background image is tiled.
type BackgroundTileMode uint8
const (
BackgroundTileNone BackgroundTileMode = iota
BackgroundTileRepeat
)
// BackgroundAnchorMode defines whether the background image scrolls with the world or stays fixed to viewport.
type BackgroundAnchorMode uint8
const (
BackgroundAnchorWorld BackgroundAnchorMode = iota
BackgroundAnchorViewport
)
// BackgroundScaleMode defines how the background image is scaled.
// (Step 1: defined for API completeness; used later when rendering background image.)
type BackgroundScaleMode uint8
const (
BackgroundScaleNone BackgroundScaleMode = iota
BackgroundScaleFit
BackgroundScaleFill
)
// StyleTheme describes a cohesive style set (theme) for rendering.
// Step 1: we store it in World and use it for background and default base styles.
// Step 2+: theme-relative overrides and background image drawing.
type StyleTheme interface {
ID() string
Name() string
BackgroundColor() color.Color
BackgroundImage() image.Image
BackgroundTileMode() BackgroundTileMode
BackgroundScaleMode() BackgroundScaleMode
BackgroundAnchorMode() BackgroundAnchorMode
PointStyle() Style
LineStyle() Style
CircleStyle() Style
// Class overrides (relative to base kind style).
// Return (override, true) when class is supported; (zero, false) means "no override".
PointClassOverride(class PointClassID) (StyleOverride, bool)
LineClassOverride(class LineClassID) (StyleOverride, bool)
CircleClassOverride(class CircleClassID) (StyleOverride, bool)
}
// DefaultTheme is a conservative theme matching built-in default styles.
type DefaultTheme struct{}
func (DefaultTheme) ID() string { return "default" }
func (DefaultTheme) Name() string { return "Default" }
func (DefaultTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (DefaultTheme) BackgroundImage() image.Image { return nil }
func (DefaultTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (DefaultTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (DefaultTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (DefaultTheme) PointStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultPoint)
return s
}
func (DefaultTheme) LineStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultLine)
return s
}
func (DefaultTheme) CircleStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultCircle)
return s
}
func (DefaultTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
-120
View File
@@ -1,120 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
const (
testPointClassExtended PointClassID = 1
testCircleClassExtended CircleClassID = 1
)
type classThemeA struct{}
func (classThemeA) ID() string { return "classA" }
func (classThemeA) Name() string { return "classA" }
func (classThemeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (classThemeA) BackgroundImage() image.Image { return nil }
func (classThemeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (classThemeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (classThemeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (classThemeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (classThemeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 6.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
func (classThemeA) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (classThemeA) CircleClassOverride(c CircleClassID) (StyleOverride, bool) {
if c == testCircleClassExtended {
w := 3.0
return StyleOverride{StrokeWidthPx: &w}, true
}
return StyleOverride{}, false
}
type classThemeB struct{ classThemeA }
func (classThemeB) ID() string { return "classB" }
func (classThemeB) Name() string { return "classB" }
func (classThemeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 3}
}
func (classThemeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 2}
}
func (classThemeB) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 9.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
func TestThemeClassOverride_AppliesAndUpdatesOnThemeChange(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
id, err := w.AddPoint(1, 1, PointWithClass(testPointClassExtended))
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 6.0, s1.PointRadiusPx)
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 9.0, s2.PointRadiusPx)
}
func TestThemeClassOverride_MergesWithUserOverride_UserWins(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
// class would set point radius to 6, but user override sets it to 12.
rUser := 12.0
id, err := w.AddPoint(1, 1,
PointWithClass(testPointClassExtended),
PointWithStyleOverride(StyleOverride{PointRadiusPx: &rUser}),
)
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s1.PointRadiusPx)
// After theme change, class would set to 9, but user override must still win.
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s2.PointRadiusPx)
}
-373
View File
@@ -1,373 +0,0 @@
package world
import (
"image"
"image/color"
)
// NOTE:
// - This file provides two sample themes: LightTheme and DarkTheme.
// - LightTheme uses only BackgroundColor.
// - DarkTheme uses BackgroundColor + a tiled, immutable BackgroundImage.
var (
ThemeLight = &LightTheme{}
ThemeDark = NewDarkTheme()
)
// -----------------------------
// Helpers
// -----------------------------
// cHex returns an sRGB color. Alpha is 0..255.
func cRGBA(r, g, b, a uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: a} }
// -----------------------------
// Light Theme (color only)
// -----------------------------
type LightTheme struct{}
func (LightTheme) ID() string { return "theme.light.v1" }
func (LightTheme) Name() string { return "Light (Soft)" }
func (LightTheme) BackgroundColor() color.Color { return cRGBA(244, 246, 248, 255) } // #F4F6F8
func (LightTheme) BackgroundImage() image.Image { return nil }
func (LightTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (LightTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (LightTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
// Base styles per primitive kind (full Style, not override).
func (LightTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(32, 161, 145, 255), // soft teal
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (LightTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(70, 108, 196, 220), // soft blue
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (LightTheme) CircleStyle() Style {
return Style{
FillColor: cRGBA(133, 110, 201, 60), // soft purple with low alpha
StrokeColor: cRGBA(133, 110, 201, 200), // soft purple
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
// muted gray-blue
return StyleOverride{
FillColor: cRGBA(120, 135, 160, 230),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
// soft green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 240),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
// soft orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 240),
PointRadiusPx: new(3.5),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
// dashed neutral line
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(100, 110, 125, 200),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassHome:
// teal-ish, a bit stronger stroke
return StyleOverride{
FillColor: cRGBA(32, 161, 145, 50),
StrokeColor: cRGBA(32, 161, 145, 210),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
// blue
return StyleOverride{
FillColor: cRGBA(70, 108, 196, 45),
StrokeColor: cRGBA(70, 108, 196, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOccupied:
// orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 50),
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFree:
// green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 45),
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// -----------------------------
// Dark Theme (color + tiled image)
// -----------------------------
type DarkTheme struct {
bg image.Image
}
func NewDarkTheme() *DarkTheme {
return &DarkTheme{bg: makeDarkBackgroundTile(96, 96)}
}
func (*DarkTheme) ID() string { return "theme.dark.v1" }
func (*DarkTheme) Name() string { return "Dark (Soft + Texture)" }
func (*DarkTheme) BackgroundColor() color.Color { return cRGBA(30, 32, 38, 255) } // #1E2026
func (t *DarkTheme) BackgroundImage() image.Image {
return nil
// This image is immutable after creation.
// return t.bg
}
func (*DarkTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (*DarkTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (*DarkTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
// Base styles for dark theme.
func (*DarkTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(120, 214, 198, 255), // brighter teal for dark bg
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (*DarkTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(155, 175, 235, 220), // soft bluish
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (*DarkTheme) CircleStyle() Style {
return Style{
FillColor: cRGBA(186, 160, 255, 55), // soft lavender, low alpha
StrokeColor: cRGBA(186, 160, 255, 200), // soft lavender
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
return StyleOverride{
FillColor: cRGBA(150, 160, 175, 230),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 245),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 245),
PointRadiusPx: new(3.5),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(132, 219, 162, 220),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(245, 178, 120, 220),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(170, 175, 190, 200),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassHome:
return StyleOverride{
FillColor: cRGBA(120, 214, 198, 50),
StrokeColor: cRGBA(120, 214, 198, 210),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
return StyleOverride{
FillColor: cRGBA(155, 175, 235, 45),
StrokeColor: cRGBA(155, 175, 235, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOccupied:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 45),
StrokeColor: cRGBA(245, 178, 120, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFree:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 45),
StrokeColor: cRGBA(132, 219, 162, 220),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// makeDarkBackgroundTile creates a subtle, low-contrast texture tile.
// It is intentionally simple: a faint grid + a few diagonal accents.
// The tile is meant to be repeated.
func makeDarkBackgroundTile(w, h int) image.Image {
if w <= 0 || h <= 0 {
return nil
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
// Base is transparent; background color is drawn separately.
// We draw subtle strokes with low alpha.
grid := color.RGBA{R: 255, G: 255, B: 255, A: 12} // very faint
diag := color.RGBA{R: 255, G: 255, B: 255, A: 18} // slightly stronger
dots := color.RGBA{R: 255, G: 255, B: 255, A: 10} // faint dots
// Grid spacing (pixels).
const step = 12
// Vertical grid lines.
for x := 0; x < w; x += step {
for y := 0; y < h; y++ {
img.SetRGBA(x, y, grid)
}
}
// Horizontal grid lines.
for y := 0; y < h; y += step {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, grid)
}
}
// Diagonal accents (sparse).
for x := 0; x < w; x += step * 2 {
for i := 0; i < step && x+i < w && i < h; i++ {
img.SetRGBA(x+i, i, diag)
}
}
// Small dot pattern.
for y := step / 2; y < h; y += step {
for x := step / 2; x < w; x += step {
img.SetRGBA(x, y, dots)
}
}
return img
}
-149
View File
@@ -1,149 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
type themeA struct{}
func (themeA) ID() string { return "A" }
func (themeA) Name() string { return "A" }
func (themeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeA) BackgroundImage() image.Image { return nil }
func (themeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (themeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (themeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (themeA) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
type themeB struct{}
func (themeB) ID() string { return "B" }
func (themeB) Name() string { return "B" }
func (themeB) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeB) BackgroundImage() image.Image { return nil }
func (themeB) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeB) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeB) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 5}
}
func (themeB) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 99, A: 255}, StrokeWidthPx: 3}
}
func (themeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 4}
}
func (themeB) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func TestThemeChange_UpdatesThemeDefaultStyleObjects(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
id, err := w.AddPoint(1, 1) // default => theme-managed
require.NoError(t, err)
p := w.objects[id].(Point)
styleBefore := p.StyleID
w.SetTheme(themeB{})
p2 := w.objects[id].(Point)
styleAfter := p2.StyleID
require.NotEqual(t, styleBefore, styleAfter)
s, ok := w.styles.Get(styleAfter)
require.True(t, ok)
// From themeB point style
require.Equal(t, 5.0, s.PointRadiusPx)
}
func TestThemeChange_UpdatesThemeRelativeOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
s1, ok := w.styles.Get(c1.StyleID)
require.True(t, ok)
// Stroke overridden to white, fill from themeA (B=10).
require.Equal(t, uint32(0xffff), alphaOf(s1.StrokeColor))
require.Equal(t, u16FromU8(10), blueOf(s1.FillColor))
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
s2, ok := w.styles.Get(c2.StyleID)
require.True(t, ok)
// Still white stroke, but fill should now come from themeB (B=99).
require.Equal(t, uint32(0xffff), alphaOf(s2.StrokeColor))
require.Equal(t, u16FromU8(99), blueOf(s2.FillColor))
}
func TestThemeChange_DoesNotAffectFixedStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
sw := 2.0
fixed := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{R: 1, A: 255},
StrokeWidthPx: &sw,
})
id, err := w.AddCircle(5, 5, 2, CircleWithStyleID(fixed))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
require.Equal(t, fixed, c1.StyleID)
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
require.Equal(t, fixed, c2.StyleID)
}
func alphaOf(c color.Color) uint32 {
_, _, _, a := c.RGBA()
return a
}
func blueOf(c color.Color) uint32 {
_, _, b, _ := c.RGBA()
return b
}
// u16FromU8 converts an 8-bit channel value to the 16-bit value returned by color.Color.RGBA().
// The standard conversion is v * 257 (0x0101) so that 0xAB becomes 0xABAB.
func u16FromU8(v uint8) uint32 {
return uint32(v) * 257
}
-144
View File
@@ -1,144 +0,0 @@
package world
import (
"image"
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
type testTheme struct{}
func (testTheme) ID() string { return "t1" }
func (testTheme) Name() string { return "Theme1" }
func (testTheme) BackgroundColor() color.Color { return color.RGBA{R: 1, G: 2, B: 3, A: 255} }
func (testTheme) BackgroundImage() image.Image { return nil }
func (testTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (testTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (testTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (testTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (testTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) PointStyle() Style {
return Style{
FillColor: color.RGBA{R: 9, A: 255},
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 4,
}
}
func (testTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: color.RGBA{G: 9, A: 255},
StrokeWidthPx: 3,
StrokeDashes: []float64{2, 2},
StrokeDashOffset: 1,
PointRadiusPx: 0,
}
}
func (testTheme) CircleStyle() Style {
return Style{
FillColor: color.RGBA{B: 9, A: 255},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: 2,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
}
func TestWorldSetTheme_MaterializesThemeDefaultStyles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Built-ins should remain stable (1/2/3).
require.Equal(t, StyleIDDefaultLine, StyleID(1))
require.Equal(t, StyleIDDefaultCircle, StyleID(2))
require.Equal(t, StyleIDDefaultPoint, StyleID(3))
// Set a custom theme.
w.SetTheme(testTheme{})
// Theme defaults should NOT be built-in IDs anymore.
require.NotEqual(t, StyleIDDefaultLine, w.themeDefaultLineStyleID)
require.NotEqual(t, StyleIDDefaultCircle, w.themeDefaultCircleStyleID)
require.NotEqual(t, StyleIDDefaultPoint, w.themeDefaultPointStyleID)
ls, ok := w.styles.Get(w.themeDefaultLineStyleID)
require.True(t, ok)
require.Equal(t, 3.0, ls.StrokeWidthPx)
require.Equal(t, []float64{2, 2}, ls.StrokeDashes)
require.Equal(t, 1.0, ls.StrokeDashOffset)
cs, ok := w.styles.Get(w.themeDefaultCircleStyleID)
require.True(t, ok)
require.Equal(t, 2.0, cs.StrokeWidthPx)
ps, ok := w.styles.Get(w.themeDefaultPointStyleID)
require.True(t, ok)
require.Equal(t, 4.0, ps.PointRadiusPx)
}
func TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
// Minimal index.
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// Should clear with theme background color via ClearAllTo(bg).
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
func TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{R: 200, A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
-135
View File
@@ -1,135 +0,0 @@
package world
// torusShortestLineSegmentsInto converts a Line primitive into 1..4 canonical segments
// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline.
//
// It appends results into dst using tmp as an intermediate buffer.
// No allocations occur if dst/tmp have sufficient capacity (>=4).
func torusShortestLineSegmentsInto(dst, tmp []lineSeg, l Line, worldW, worldH int) ([]lineSeg, []lineSeg) {
dst = dst[:0]
tmp = tmp[:0]
// Step 1: choose the torus-shortest representation in unwrapped space.
ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW)
ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH)
// Step 2: shift so that A is inside canonical [0..W) x [0..H).
shiftX := floorDiv(ax, worldW) * worldW
shiftY := floorDiv(ay, worldH) * worldH
ax -= shiftX
bx -= shiftX
ay -= shiftY
by -= shiftY
dst = append(dst, lineSeg{x1: ax, y1: ay, x2: bx, y2: by})
// Step 3: split by X boundary if needed (jump-aware).
tmp = splitSegmentsByXInto(tmp, dst, worldW)
// Step 4: split by Y boundary if needed (jump-aware).
dst = splitSegmentsByYInto(dst, tmp, worldH)
return dst, tmp
}
// torusShortestLineSegments is a compatibility wrapper that allocates.
// Prefer torusShortestLineSegmentsInto in hot paths.
func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg {
dst := make([]lineSeg, 0, 4)
tmp := make([]lineSeg, 0, 4)
dst, _ = torusShortestLineSegmentsInto(dst, tmp, l, worldW, worldH)
return dst
}
// splitSegmentsByXInto appends 1..2 segments for each input segment into out, without allocating.
// out is reset to length 0 by this function.
func splitSegmentsByXInto(out []lineSeg, segs []lineSeg, worldW int) []lineSeg {
out = out[:0]
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, x1 is expected inside [0..worldW). Only x2 may be outside.
if x2 >= 0 && x2 < worldW {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dx == 0 {
out = append(out, s)
continue
}
if x2 >= worldW {
// Crosses the right boundary at x=worldW, then reappears at x=0.
bx := worldW
num := bx - x1
iy := y1 + (dy*num)/dx
s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy}
s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2}
out = append(out, s1, s2)
continue
}
// x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW.
bx := 0
num := bx - x1
iy := y1 + (dy*num)/dx
s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy}
s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2}
out = append(out, s1, s2)
}
return out
}
// splitSegmentsByYInto appends 1..2 segments for each input segment into out, without allocating.
// out is reset to length 0 by this function.
func splitSegmentsByYInto(out []lineSeg, segs []lineSeg, worldH int) []lineSeg {
out = out[:0]
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, y1 is expected inside [0..worldH). Only y2 may be outside.
if y2 >= 0 && y2 < worldH {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dy == 0 {
out = append(out, s)
continue
}
if y2 >= worldH {
// Crosses the top boundary at y=worldH, then reappears at y=0.
by := worldH
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH}
s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH}
out = append(out, s1, s2)
continue
}
// y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH.
by := 0
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0}
s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH}
out = append(out, s1, s2)
}
return out
}
+1182
View File
File diff suppressed because it is too large Load Diff
+606 -2
View File
@@ -1,12 +1,12 @@
package world
import (
"github.com/stretchr/testify/require"
"math"
"testing"
"github.com/stretchr/testify/require"
)
// TestWrap verifies wrap.
func TestWrap(t *testing.T) {
t.Parallel()
@@ -36,6 +36,7 @@ func TestWrap(t *testing.T) {
}
}
// TestClamp verifies clamp.
func TestClamp(t *testing.T) {
t.Parallel()
@@ -65,6 +66,7 @@ func TestClamp(t *testing.T) {
}
}
// TestCeilDiv verifies ceil Div.
func TestCeilDiv(t *testing.T) {
t.Parallel()
@@ -89,6 +91,7 @@ func TestCeilDiv(t *testing.T) {
}
}
// TestFloorDiv verifies floor Div.
func TestFloorDiv(t *testing.T) {
t.Parallel()
@@ -107,6 +110,7 @@ func TestFloorDiv(t *testing.T) {
require.Panics(t, func() { _ = floorDiv(1, -1) })
}
// TestFixedPoint verifies fixed Point.
func TestFixedPoint(t *testing.T) {
t.Parallel()
@@ -135,6 +139,7 @@ func TestFixedPoint(t *testing.T) {
}
}
// TestAbs verifies abs.
func TestAbs(t *testing.T) {
t.Parallel()
@@ -154,6 +159,7 @@ func TestAbs(t *testing.T) {
}
}
// TestPixelSpanToWorldFixed verifies pixel Span To World Fixed.
func TestPixelSpanToWorldFixed(t *testing.T) {
t.Parallel()
@@ -181,6 +187,7 @@ func TestPixelSpanToWorldFixed(t *testing.T) {
}
}
// TestWorldToCellPanicsOnInvalidGrid verifies world To Cell Panics On Invalid Grid.
func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
t.Parallel()
@@ -211,6 +218,7 @@ func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
}
}
// TestShortestWrappedDelta verifies shortest Wrapped Delta.
func TestShortestWrappedDelta(t *testing.T) {
t.Parallel()
@@ -252,6 +260,7 @@ func TestShortestWrappedDelta(t *testing.T) {
}
}
// TestCameraZoomToWorldFixed verifies camera Zoom To World Fixed.
func TestCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
@@ -309,6 +318,7 @@ func TestCameraZoomToWorldFixed(t *testing.T) {
}
}
// TestCameraZoomToWorldFixedReturnsError verifies camera Zoom To World Fixed Returns Error.
func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
t.Parallel()
@@ -354,6 +364,7 @@ func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
}
}
// TestMustCameraZoomToWorldFixed verifies must Camera Zoom To World Fixed.
func TestMustCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
@@ -363,3 +374,596 @@ func TestMustCameraZoomToWorldFixed(t *testing.T) {
_ = mustCameraZoomToWorldFixed(0)
})
}
// TestWorldFixedToCameraZoom verifies world Fixed To Camera Zoom.
func TestWorldFixedToCameraZoom(t *testing.T) {
t.Parallel()
tests := []struct {
name string
zoomFp int
want float64
}{
{name: "zero", zoomFp: 0, want: 0},
{name: "neutral", zoomFp: SCALE, want: 1.0},
{name: "fractional", zoomFp: 1250, want: 1.25},
{name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := worldFixedToCameraZoom(tt.zoomFp)
require.Equal(t, tt.want, got)
})
}
}
// TestRequiredZoomToFitWorld verifies required Zoom To Fit World.
func TestRequiredZoomToFitWorld(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
want int
}{
{
name: "zero viewport span",
viewportSpanPx: 0,
worldSpanFp: 10 * SCALE,
want: 0,
},
{
name: "exact neutral fit",
viewportSpanPx: 10,
worldSpanFp: 10 * SCALE,
want: SCALE,
},
{
name: "exact 2x fit",
viewportSpanPx: 20,
worldSpanFp: 10 * SCALE,
want: 2 * SCALE,
},
{
name: "fractional fit rounded up",
viewportSpanPx: 11,
worldSpanFp: 10 * SCALE,
want: 1100,
},
{
name: "small world requires larger zoom",
viewportSpanPx: 320,
worldSpanFp: 80 * SCALE,
want: 4 * SCALE,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
require.Equal(t, tt.want, got)
})
}
}
// TestRequiredZoomToFitWorldPanics verifies required Zoom To Fit World Panics.
func TestRequiredZoomToFitWorldPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
}{
{
name: "negative viewport span",
viewportSpanPx: -1,
worldSpanFp: 10 * SCALE,
},
{
name: "zero world span",
viewportSpanPx: 10,
worldSpanFp: 0,
},
{
name: "negative world span",
viewportSpanPx: 10,
worldSpanFp: -1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
})
})
}
}
// TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded verifies correct Camera Zoom Fp Returns Current When No Correction Needed.
func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
2*SCALE,
40, 30,
100*SCALE, 100*SCALE,
MIN_ZOOM, MAX_ZOOM,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth verifies correct Camera Zoom Fp Raises Zoom To Fit World Width.
func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got)
}
// TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight verifies correct Camera Zoom Fp Raises Zoom To Fit World Height.
func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpUsesMaxFitAcrossAxes verifies correct Camera Zoom Fp Uses Max Fit Across Axes.
func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit verifies correct Camera Zoom Fp Applies Min Zoom When Larger Than Current And Fit.
func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Fit Conflict.
func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore verifies correct Camera Zoom Fp Ignores Max Zoom When Fit Needs More.
func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid verifies correct Camera Zoom Fp Applies Min Then Max When Both Valid.
func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 1600,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped verifies correct Camera Zoom Fp Current Above Max Gets Clamped.
func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
5*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpZeroBoundsAreIgnored verifies correct Camera Zoom Fp Zero Bounds Are Ignored.
func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
1250,
20, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1250, got)
}
// TestCorrectCameraZoomFpPanics verifies correct Camera Zoom Fp Panics.
func TestCorrectCameraZoomFpPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fn func()
}{
{
name: "non-positive current zoom",
fn: func() {
_ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport width",
fn: func() {
_ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world width",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0)
},
},
{
name: "negative min zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0)
},
},
{
name: "negative max zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1)
},
},
{
name: "min greater than max",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, tt.fn)
})
}
}
// TestWorldCorrectCameraZoomReturnsFloatValue verifies world Correct Camera Zoom Returns Float Value.
func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(1.0, 120, 20)
require.Equal(t, 1.2, got)
}
// TestWorldCorrectCameraZoomAppliesDefaultBounds verifies world Correct Camera Zoom Applies Default Bounds.
func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(100.0, 20, 20)
require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got)
}
// TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound verifies world Correct Camera Zoom Fit Beats Default Max Bound.
func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) {
t.Parallel()
w := NewWorld(1, 100)
got := w.CorrectCameraZoom(1.0, 40, 10)
require.Equal(t, 40.0, got)
}
// TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld verifies correct Camera Zoom Fp Does Not Lower Zoom When Viewport Is Smaller Than World.
func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE, // currentZoomFp = 1.0x
80, 80, // viewport px
100*SCALE, 100*SCALE, // world fp
0, 0,
)
// No anti-wrap needed, and we do not auto-fit by lowering zoom.
require.Equal(t, SCALE, got)
}
// TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger verifies correct Camera Zoom Fp Raises Zoom To Prevent Wrap When Viewport Is Larger.
func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) {
t.Parallel()
// World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large.
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got) // 1.2x
}
// TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Wrap Conflict.
func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE, // user wants 4x
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE, // max 3x
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom verifies correct Camera Zoom Fp Anti Wrap Beats Max Zoom.
func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) {
t.Parallel()
// requiredFit = 2x, but max is 1.5x => must return 2x.
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFp_AppliesMinZoom verifies correct Camera Zoom Fp Applies Min Zoom.
func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
800, // 0.8x
20, 20,
100*SCALE, 100*SCALE,
SCALE, 0, // min 1.0x
)
require.Equal(t, SCALE, got)
}
// TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld verifies clamp Camera No Wrap Viewport Clamps To Keep Viewport Inside World.
func TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld(t *testing.T) {
t.Parallel()
worldW := 100 * SCALE
worldH := 100 * SCALE
zoomFp := SCALE // 1.0x
// viewport 40px => span 40 units => half 20.
viewportW, viewportH := 40, 40
// Too far left/up => clamp to minCam=20
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 20*SCALE, cx)
require.Equal(t, 20*SCALE, cy)
// Too far right/down => clamp to maxCam=world-half=80
cx, cy = ClampCameraNoWrapViewport(
99*SCALE, 99*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 80*SCALE, cx)
require.Equal(t, 80*SCALE, cy)
// Inside range => unchanged
cx, cy = ClampCameraNoWrapViewport(
50*SCALE, 60*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
// TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter verifies clamp Camera No Wrap Viewport When Viewport Larger Than World Forces Center.
func TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter(t *testing.T) {
t.Parallel()
worldW := 50 * SCALE
worldH := 50 * SCALE
zoomFp := SCALE
// viewport 60px => span 60 units > world 50
viewportW, viewportH := 60, 60
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, worldW/2, cx)
require.Equal(t, worldH/2, cy)
}
// TestWorldClampRenderParamsNoWrap_UsesViewportClamp verifies world Clamp Render Params No Wrap Uses Viewport Clamp.
func TestWorldClampRenderParamsNoWrap_UsesViewportClamp(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
p := RenderParams{
ViewportWidthPx: 40,
ViewportHeightPx: 40,
MarginXPx: 10,
MarginYPx: 10,
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.ClampRenderParamsNoWrap(&p)
// viewport half is 20, not 30 (margins ignored)
require.Equal(t, 20*SCALE, p.CameraXWorldFp)
require.Equal(t, 20*SCALE, p.CameraYWorldFp)
}
// TestPivotZoom_CursorAtCenter_KeepsCamera verifies pivot Zoom Cursor At Center Keeps Camera.
func TestPivotZoom_CursorAtCenter_KeepsCamera(t *testing.T) {
t.Parallel()
cx, cy := PivotZoomCameraNoWrap(
50*SCALE, 60*SCALE,
100, 80,
50, 40, // cursor at center
SCALE, 2*SCALE,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
// TestPivotZoom_RightEdge_ZoomInMovesCameraRight verifies pivot Zoom Right Edge Zoom In Moves Camera Right.
func TestPivotZoom_RightEdge_ZoomInMovesCameraRight(t *testing.T) {
t.Parallel()
// viewport 100px, cursor at x=100 (right edge), center is 50 => offX=50px
// zoom 1->2 halves worldPerPx, so camera must move towards the cursor to keep the same world point.
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
100, 50,
SCALE, 2*SCALE,
)
require.Greater(t, cx, cx0)
}
// TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft verifies pivot Zoom Left Edge Zoom In Moves Camera Left.
func TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft(t *testing.T) {
t.Parallel()
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
0, 50,
SCALE, 2*SCALE,
)
require.Less(t, cx, cx0)
}
+360 -7
View File
@@ -12,12 +12,17 @@ var (
errIDExhausted = errors.New("primitive id exhausted")
)
// indexState stores the viewport-dependent parameters required to rebuild the
// spatial grid after object or style-affecting mutations.
type indexState struct {
initialized bool
viewportW int
viewportH int
zoomFp int
}
// derivedStyleKey identifies one cached derived style by its base style and
// stable override fingerprint.
type derivedStyleKey struct {
base StyleID
fp uint64
@@ -163,7 +168,7 @@ func (w *World) SetTheme(theme StyleTheme) {
w.themeDefaultCircleStyleID = w.styles.AddStyle(theme.CircleStyle())
w.themeDefaultPointStyleID = w.styles.AddStyle(theme.PointStyle())
w.reresolveThemeManagedStyles()
w.refreshThemeManagedStyles()
// Full redraw to apply new background and base styles.
w.renderState.Reset()
@@ -183,7 +188,9 @@ func (w *World) themeBaseStyleID(base styleBase) StyleID {
}
}
func (w *World) reresolveThemeManagedStyles() {
// refreshThemeManagedStyles recomputes resolved StyleID values for primitives
// that track the active theme rather than a fixed explicit style.
func (w *World) refreshThemeManagedStyles() {
th := w.Theme()
for id, it := range w.objects {
@@ -243,13 +250,15 @@ func (w *World) reresolveThemeManagedStyles() {
w.objects[id] = v
default:
panic("reresolveThemeManagedStyles: unknown item type")
panic("refreshThemeManagedStyles: unknown item type")
}
}
w.ForceFullRedrawNext()
}
// derivedStyleID resolves a derived style, reusing the per-world cache when an
// identical base style and override combination has already been materialized.
func (w *World) derivedStyleID(base StyleID, ov StyleOverride) StyleID {
if ov.IsZero() {
return base
@@ -297,7 +306,7 @@ func (w *World) rebuildIndexFromLastState() {
return
}
w.indexOnViewportChangeZoomFp(w.index.viewportW, w.index.viewportH, w.index.zoomFp)
w.rebuildIndexForViewportZoomFp(w.index.viewportW, w.index.viewportH, w.index.zoomFp)
w.indexDirty = false
}
@@ -598,12 +607,16 @@ func (w *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cam
w.index.viewportH = viewportHeightPx
w.index.zoomFp = zoomFp
w.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
w.rebuildIndexForViewportZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
w.indexDirty = false
}
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
func (w *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
// rebuildIndexForViewportZoomFp rebuilds the spatial grid for a particular
// viewport size and fixed-point zoom.
//
// The chosen cell size is derived from the currently visible world span and
// then clamped into the package-wide cell-size bounds.
func (w *World) rebuildIndexForViewportZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
cellsAcrossMin := 8
@@ -659,3 +672,343 @@ func circleRadiusEffFp(rawRadiusFp, circleRadiusScaleFp int) int {
}
return int(v)
}
// PointClassID classifies Point primitives for theme-level style overrides.
//
// Themes may use the class to derive a final style from the point base style
// without changing the primitive geometry itself.
type PointClassID uint8
const (
// PointClassDefault selects the theme's default point styling.
PointClassDefault PointClassID = iota
// PointClassTrackUnknown marks a point as an unknown track marker.
PointClassTrackUnknown
// PointClassTrackIncoming marks a point as an incoming track marker.
PointClassTrackIncoming
// PointClassTrackOutgoing marks a point as an outgoing track marker.
PointClassTrackOutgoing
)
// LineClassID classifies Line primitives for theme-level style overrides.
type LineClassID uint8
const (
// LineClassDefault selects the theme's default line styling.
LineClassDefault LineClassID = iota
// LineClassTrackIncoming marks a line as an incoming track.
LineClassTrackIncoming
// LineCLassTrackOutgoing marks a line as an outgoing track.
// The unusual spelling is preserved for backward compatibility.
LineCLassTrackOutgoing
// LineClassMeasurement marks a line as a measurement helper.
LineClassMeasurement
)
// CircleClassID classifies Circle primitives for theme-level style overrides.
type CircleClassID uint8
const (
// CircleClassDefault selects the theme's default circle styling.
CircleClassDefault CircleClassID = iota
// CircleClassHome marks a circle as a home-world area.
CircleClassHome
// CircleClassAcquired marks a circle as an acquired world area.
CircleClassAcquired
// CircleClassOccupied marks a circle as an occupied world area.
CircleClassOccupied
// CircleClassFree marks a circle as a free world area.
CircleClassFree
)
// PrimitiveID is a compact stable identifier for primitives stored in the World.
// It is allocated by the World and may be reused after deletion (free-list).
type PrimitiveID uint32
// MapItem is the common interface implemented by all world primitives.
type MapItem interface {
ID() PrimitiveID
}
// styleBase describes how a primitive resolves its base style across theme changes.
type styleBase uint8
const (
styleBaseFixed styleBase = iota
styleBaseThemeLine
styleBaseThemeCircle
styleBaseThemePoint
)
// Point is a point primitive in fixed-point world coordinates.
type Point struct {
Id PrimitiveID
X, Y int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class PointClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Line is a line segment primitive in fixed-point world coordinates.
type Line struct {
Id PrimitiveID
X1, Y1 int
X2, Y2 int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class LineClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Circle is a circle primitive in fixed-point world coordinates.
type Circle struct {
Id PrimitiveID
X, Y int
Radius int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes.
Base styleBase
// Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero).
Override StyleOverride
Class CircleClassID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// ID returns the point identifier.
func (p Point) ID() PrimitiveID { return p.Id }
// ID returns the line identifier.
func (l Line) ID() PrimitiveID { return l.Id }
// ID returns the circle identifier.
func (c Circle) ID() PrimitiveID { return c.Id }
// MinX returns the minimum X endpoint coordinate of the line.
func (l Line) MinX() int { return min(l.X1, l.X2) }
// MaxX returns the maximum X endpoint coordinate of the line.
func (l Line) MaxX() int { return max(l.X1, l.X2) }
// MinY returns the minimum Y endpoint coordinate of the line.
func (l Line) MinY() int { return min(l.Y1, l.Y2) }
// MaxY returns the maximum Y endpoint coordinate of the line.
func (l Line) MaxY() int { return max(l.Y1, l.Y2) }
// MinX returns the minimum X coordinate of the circle bbox.
func (c Circle) MinX() int { return c.X - c.Radius }
// MaxX returns the maximum X coordinate of the circle bbox.
func (c Circle) MaxX() int { return c.X + c.Radius }
// MinY returns the minimum Y coordinate of the circle bbox.
func (c Circle) MinY() int { return c.Y - c.Radius }
// MaxY returns the maximum Y coordinate of the circle bbox.
func (c Circle) MaxY() int { return c.Y + c.Radius }
// PointOpt applies optional point-construction parameters to PointOptions.
type PointOpt func(*PointOptions)
// PointOptions stores optional arguments accepted by World.AddPoint.
//
// Defaults are resolved before applying user-provided PointOpt values.
type PointOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class PointClassID
HitSlopPx int
hasStyleID bool
}
// defaultPointOptions returns the default option set used by World.AddPoint.
func defaultPointOptions() PointOptions {
return PointOptions{
Priority: DefaultPriorityPoint,
StyleID: StyleIDDefaultPoint,
Class: PointClassDefault,
}
}
// PointWithPriority sets point draw priority.
//
// Lower priorities render earlier within the same tile.
func PointWithPriority(p int) PointOpt {
return func(o *PointOptions) {
o.Priority = p
}
}
// PointWithStyleID forces the point to use a pre-registered style.
func PointWithStyleID(id StyleID) PointOpt {
return func(o *PointOptions) {
o.StyleID = id
o.hasStyleID = true
// Explicit style ID wins over overrides.
o.Override = StyleOverride{}
}
}
// PointWithClass selects the theme class used for point style resolution.
func PointWithClass(c PointClassID) PointOpt {
return func(o *PointOptions) { o.Class = c }
}
// PointWithStyleOverride applies a user override on top of the resolved point base style.
//
// If PointWithStyleID is also supplied, the explicit style ID wins.
func PointWithStyleOverride(ov StyleOverride) PointOpt {
return func(o *PointOptions) {
o.Override = ov
}
}
// PointWithHitSlopPx overrides the default point hit slop in screen pixels.
func PointWithHitSlopPx(px int) PointOpt {
return func(o *PointOptions) { o.HitSlopPx = px }
}
// CircleOpt applies optional circle-construction parameters to CircleOptions.
type CircleOpt func(*CircleOptions)
// CircleOptions stores optional arguments accepted by World.AddCircle.
type CircleOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class CircleClassID
HitSlopPx int
hasStyleID bool
}
// defaultCircleOptions returns the default option set used by World.AddCircle.
func defaultCircleOptions() CircleOptions {
return CircleOptions{
Priority: DefaultPriorityCircle,
StyleID: StyleIDDefaultCircle,
Class: CircleClassDefault,
}
}
// CircleWithPriority sets circle draw priority.
func CircleWithPriority(p int) CircleOpt {
return func(o *CircleOptions) {
o.Priority = p
}
}
// CircleWithStyleID forces the circle to use a pre-registered style.
func CircleWithStyleID(id StyleID) CircleOpt {
return func(o *CircleOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
// CircleWithClass selects the theme class used for circle style resolution.
func CircleWithClass(c CircleClassID) CircleOpt {
return func(o *CircleOptions) { o.Class = c }
}
// CircleWithStyleOverride applies a user override on top of the resolved circle base style.
func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
return func(o *CircleOptions) {
o.Override = ov
}
}
// CircleWithHitSlopPx overrides the default circle hit slop in screen pixels.
func CircleWithHitSlopPx(px int) CircleOpt {
return func(o *CircleOptions) { o.HitSlopPx = px }
}
// LineOpt applies optional line-construction parameters to LineOptions.
type LineOpt func(*LineOptions)
// LineOptions stores optional arguments accepted by World.AddLine.
type LineOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
Class LineClassID
HitSlopPx int
hasStyleID bool
}
// defaultLineOptions returns the default option set used by World.AddLine.
func defaultLineOptions() LineOptions {
return LineOptions{
Priority: DefaultPriorityLine,
StyleID: StyleIDDefaultLine,
Class: LineClassDefault,
}
}
// LineWithPriority sets line draw priority.
func LineWithPriority(p int) LineOpt {
return func(o *LineOptions) {
o.Priority = p
}
}
// LineWithStyleID forces the line to use a pre-registered style.
func LineWithStyleID(id StyleID) LineOpt {
return func(o *LineOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
// LineWithClass selects the theme class used for line style resolution.
func LineWithClass(c LineClassID) LineOpt {
return func(o *LineOptions) { o.Class = c }
}
// LineWithStyleOverride applies a user override on top of the resolved line base style.
func LineWithStyleOverride(ov StyleOverride) LineOpt {
return func(o *LineOptions) {
o.Override = ov
}
}
// LineWithHitSlopPx overrides the default line hit slop in screen pixels.
func LineWithHitSlopPx(px int) LineOpt {
return func(o *LineOptions) { o.HitSlopPx = px }
}
-37
View File
@@ -1,37 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldPrimitiveID_ReusesFreedIDs(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id1, err := w.AddPoint(1, 1)
require.NoError(t, err)
id2, err := w.AddPoint(2, 2)
require.NoError(t, err)
require.NotEqual(t, id1, id2)
require.NoError(t, w.Remove(id1))
id3, err := w.AddPoint(3, 3)
require.NoError(t, err)
// LIFO free-list: id1 should be reused.
require.Equal(t, id1, id3)
}
func TestWorldRemove_UnknownID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
err := w.Remove(12345)
require.ErrorIs(t, err, errNoSuchObject)
}
-110
View File
@@ -1,110 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestAddPoint_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddPoint(1, 1)
require.NoError(t, err)
obj := w.objects[id].(Point)
require.Equal(t, DefaultPriorityPoint, obj.Priority)
require.Equal(t, StyleIDDefaultPoint, obj.StyleID)
}
func TestAddCircle_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddCircle(1, 1, 1)
require.NoError(t, err)
obj := w.objects[id].(Circle)
require.Equal(t, DefaultPriorityCircle, obj.Priority)
require.Equal(t, StyleIDDefaultCircle, obj.StyleID)
}
func TestAddLine_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddLine(1, 1, 2, 2)
require.NoError(t, err)
obj := w.objects[id].(Line)
require.Equal(t, DefaultPriorityLine, obj.Priority)
require.Equal(t, StyleIDDefaultLine, obj.StyleID)
}
func TestAddStyleLine_ThenUseStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
width := 5.0
ov := StyleOverride{StrokeWidthPx: &width}
styleID := w.AddStyleLine(ov)
id, err := w.AddLine(1, 1, 2, 2, LineWithStyleID(styleID), LineWithPriority(777))
require.NoError(t, err)
obj := w.objects[id].(Line)
require.Equal(t, 777, obj.Priority)
require.Equal(t, styleID, obj.StyleID)
s, ok := w.styles.Get(styleID)
require.True(t, ok)
require.Equal(t, 5.0, s.StrokeWidthPx)
}
func TestAddPoint_WithOverride_CreatesDerivedStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
newRadius := 9.0
ov := StyleOverride{PointRadiusPx: &newRadius}
id, err := w.AddPoint(1, 1, PointWithStyleOverride(ov))
require.NoError(t, err)
obj := w.objects[id].(Point)
require.NotEqual(t, StyleIDDefaultPoint, obj.StyleID)
s, ok := w.styles.Get(obj.StyleID)
require.True(t, ok)
require.Equal(t, 9.0, s.PointRadiusPx)
}
func TestExplicitStyleID_WinsOverOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
red := color.RGBA{R: 255, A: 255}
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
// Try to override radius in options too; StyleID must win, override must be ignored.
width := 123.0
id, err := w.AddCircle(2, 2, 1,
CircleWithStyleID(styleID),
CircleWithStyleOverride(StyleOverride{StrokeWidthPx: &width}),
)
require.NoError(t, err)
obj := w.objects[id].(Circle)
require.Equal(t, styleID, obj.StyleID)
s, ok := w.styles.Get(styleID)
require.True(t, ok)
require.Equal(t, red, s.FillColor)
// width override must not affect styleID.
require.NotEqual(t, 123.0, s.StrokeWidthPx)
}
File diff suppressed because it is too large Load Diff
-120
View File
@@ -1,120 +0,0 @@
package world
// worldFixedToCameraZoom converts a fixed-point zoom value back into the
// UI-facing floating-point representation where 1.0 means neutral zoom.
func worldFixedToCameraZoom(zoomFp int) float64 {
return float64(zoomFp) / float64(SCALE)
}
// requiredZoomToFitWorld returns the minimum fixed-point zoom needed so that
// a viewport span of viewportSpanPx pixels does not exceed a world span of
// worldSpanFp fixed-point units.
//
// The result is rounded up, not down, because the fit constraint must be
// satisfied conservatively: after correction, the visible world span must
// never be larger than the actual world span.
func requiredZoomToFitWorld(viewportSpanPx, worldSpanFp int) int {
if viewportSpanPx < 0 {
panic("requiredZoomToFitWorld: negative viewport span")
}
if worldSpanFp <= 0 {
panic("requiredZoomToFitWorld: non-positive world span")
}
if viewportSpanPx == 0 {
return 0
}
return ceilDiv(viewportSpanPx*SCALE*SCALE, worldSpanFp)
}
// correctCameraZoomFp corrects a fixed-point zoom value using two groups
// of constraints:
//
// 1. Fit-to-world constraints derived from viewport and world sizes.
// These have the highest priority and prevent the viewport from becoming
// larger than the world on any axis, which would otherwise expose wrap
// on the visible user area.
//
// 2. Optional UI zoom bounds [minZoomFp, maxZoomFp].
// A zero bound means "ignore this bound".
// If fit-to-world requires a zoom larger than maxZoomFp, the fit constraint
// wins and maxZoomFp is ignored for that case.
//
// The function returns either the corrected zoom or currentZoomFp unchanged
// when no correction is required.
func correctCameraZoomFp(
currentZoomFp int,
viewportWidthPx, viewportHeightPx int,
worldWidthFp, worldHeightFp int,
minZoomFp, maxZoomFp int,
) int {
if currentZoomFp <= 0 {
panic("correctCameraZoomFp: non-positive current zoom")
}
if viewportWidthPx < 0 || viewportHeightPx < 0 {
panic("correctCameraZoomFp: negative viewport size")
}
if worldWidthFp <= 0 || worldHeightFp <= 0 {
panic("correctCameraZoomFp: non-positive world size")
}
if minZoomFp < 0 || maxZoomFp < 0 {
panic("correctCameraZoomFp: negative zoom bound")
}
if minZoomFp > 0 && maxZoomFp > 0 && minZoomFp > maxZoomFp {
panic("correctCameraZoomFp: min zoom greater than max zoom")
}
// Start from the user zoom.
result := currentZoomFp
// Apply min bound first (only increases zoom, always valid).
if minZoomFp > 0 && result < minZoomFp {
result = minZoomFp
}
// Apply max bound tentatively. This can be overridden later by the anti-wrap constraint.
if maxZoomFp > 0 && result > maxZoomFp {
result = maxZoomFp
}
// If viewport is larger than the world on any axis at the current result zoom,
// increase zoom to the minimum value that prevents wrap in the visible area.
requiredFitX := requiredZoomToFitWorld(viewportWidthPx, worldWidthFp)
requiredFitY := requiredZoomToFitWorld(viewportHeightPx, worldHeightFp)
requiredFit := max(requiredFitX, requiredFitY)
if requiredFit > 0 && result < requiredFit {
result = requiredFit
}
// Re-apply max bound only if it does not conflict with the anti-wrap requirement.
// If anti-wrap requires zoom > maxZoomFp, anti-wrap wins.
if maxZoomFp > 0 && result > maxZoomFp && requiredFit <= maxZoomFp {
result = maxZoomFp
}
return result
}
// CorrectCameraZoom adapts fixed-point zoom correction for UI code.
//
// currentZoom is the user-facing zoom multiplier in floating-point form.
// The result is returned in the same representation.
func (w *World) CorrectCameraZoom(
currentZoom float64,
viewportWidthPx int,
viewportHeightPx int,
) float64 {
currentZoomFp := mustCameraZoomToWorldFixed(currentZoom)
correctedZoomFp := correctCameraZoomFp(
currentZoomFp,
viewportWidthPx,
viewportHeightPx,
w.W,
w.H,
MIN_ZOOM,
MAX_ZOOM,
)
return worldFixedToCameraZoom(correctedZoomFp)
}
-442
View File
@@ -1,442 +0,0 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldFixedToCameraZoom(t *testing.T) {
t.Parallel()
tests := []struct {
name string
zoomFp int
want float64
}{
{name: "zero", zoomFp: 0, want: 0},
{name: "neutral", zoomFp: SCALE, want: 1.0},
{name: "fractional", zoomFp: 1250, want: 1.25},
{name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := worldFixedToCameraZoom(tt.zoomFp)
require.Equal(t, tt.want, got)
})
}
}
func TestRequiredZoomToFitWorld(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
want int
}{
{
name: "zero viewport span",
viewportSpanPx: 0,
worldSpanFp: 10 * SCALE,
want: 0,
},
{
name: "exact neutral fit",
viewportSpanPx: 10,
worldSpanFp: 10 * SCALE,
want: SCALE,
},
{
name: "exact 2x fit",
viewportSpanPx: 20,
worldSpanFp: 10 * SCALE,
want: 2 * SCALE,
},
{
name: "fractional fit rounded up",
viewportSpanPx: 11,
worldSpanFp: 10 * SCALE,
want: 1100,
},
{
name: "small world requires larger zoom",
viewportSpanPx: 320,
worldSpanFp: 80 * SCALE,
want: 4 * SCALE,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
require.Equal(t, tt.want, got)
})
}
}
func TestRequiredZoomToFitWorldPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
}{
{
name: "negative viewport span",
viewportSpanPx: -1,
worldSpanFp: 10 * SCALE,
},
{
name: "zero world span",
viewportSpanPx: 10,
worldSpanFp: 0,
},
{
name: "negative world span",
viewportSpanPx: 10,
worldSpanFp: -1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
})
})
}
}
func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
2*SCALE,
40, 30,
100*SCALE, 100*SCALE,
MIN_ZOOM, MAX_ZOOM,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got)
}
func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 1600,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
5*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
1250,
20, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1250, got)
}
func TestCorrectCameraZoomFpPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fn func()
}{
{
name: "non-positive current zoom",
fn: func() {
_ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport width",
fn: func() {
_ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world width",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0)
},
},
{
name: "negative min zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0)
},
},
{
name: "negative max zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1)
},
},
{
name: "min greater than max",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, tt.fn)
})
}
}
func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(1.0, 120, 20)
require.Equal(t, 1.2, got)
}
func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(100.0, 20, 20)
require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got)
}
func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) {
t.Parallel()
w := NewWorld(1, 100)
got := w.CorrectCameraZoom(1.0, 40, 10)
require.Equal(t, 40.0, got)
}
func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE, // currentZoomFp = 1.0x
80, 80, // viewport px
100*SCALE, 100*SCALE, // world fp
0, 0,
)
// No anti-wrap needed, and we do not auto-fit by lowering zoom.
require.Equal(t, SCALE, got)
}
func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) {
t.Parallel()
// World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large.
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got) // 1.2x
}
func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE, // user wants 4x
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE, // max 3x
)
require.Equal(t, 3*SCALE, got)
}
func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) {
t.Parallel()
// requiredFit = 2x, but max is 1.5x => must return 2x.
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
800, // 0.8x
20, 20,
100*SCALE, 100*SCALE,
SCALE, 0, // min 1.0x
)
require.Equal(t, SCALE, got)
}
func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}