diff --git a/client/loader/loader.go b/client/loader/loader.go index 44f4391..60c2285 100644 --- a/client/loader/loader.go +++ b/client/loader/loader.go @@ -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) diff --git a/client/world/README.md b/client/world/README.md new file mode 100644 index 0000000..4c8f708 --- /dev/null +++ b/client/world/README.md @@ -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. diff --git a/client/world/camera_clamp.go b/client/world/camera_clamp.go deleted file mode 100644 index b329887..0000000 --- a/client/world/camera_clamp.go +++ /dev/null @@ -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 -} diff --git a/client/world/camera_clamp_test.go b/client/world/camera_clamp_test.go deleted file mode 100644 index 8942f7f..0000000 --- a/client/world/camera_clamp_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/circle_radius_scale_test.go b/client/world/circle_radius_scale_test.go deleted file mode 100644 index 12160d5..0000000 --- a/client/world/circle_radius_scale_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/classes.go b/client/world/classes.go deleted file mode 100644 index d73c079..0000000 --- a/client/world/classes.go +++ /dev/null @@ -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 -) diff --git a/client/world/drawer.go b/client/world/drawer.go index 8d208bc..046d1e3 100644 --- a/client/world/drawer.go +++ b/client/world/drawer.go @@ -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) +} diff --git a/client/world/drawer_clear_state_test.go b/client/world/drawer_clear_state_test.go deleted file mode 100644 index 426c12c..0000000 --- a/client/world/drawer_clear_state_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/drawer_test.go b/client/world/drawer_test.go index dc197fe..a69dd32 100644 --- a/client/world/drawer_test.go +++ b/client/world/drawer_test.go @@ -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) +} diff --git a/client/world/fake_drawer_helpers_test.go b/client/world/fake_drawer_helpers_test.go deleted file mode 100644 index d37eba8..0000000 --- a/client/world/fake_drawer_helpers_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/fake_drawer_test.go b/client/world/fake_drawer_test.go deleted file mode 100644 index b5d7e8c..0000000 --- a/client/world/fake_drawer_test.go +++ /dev/null @@ -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] -} diff --git a/client/world/gg_drawer_background_bench_test.go b/client/world/gg_drawer_background_bench_test.go deleted file mode 100644 index 5538400..0000000 --- a/client/world/gg_drawer_background_bench_test.go +++ /dev/null @@ -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) - } -} diff --git a/client/world/gg_drawer_background_fast.go b/client/world/gg_drawer_background_fast.go deleted file mode 100644 index f4dcb0f..0000000 --- a/client/world/gg_drawer_background_fast.go +++ /dev/null @@ -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) -} diff --git a/client/world/hit_circle_strict_test.go b/client/world/hit_circle_strict_test.go deleted file mode 100644 index 6849572..0000000 --- a/client/world/hit_circle_strict_test.go +++ /dev/null @@ -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, ¶ms, 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) - }) - } -} diff --git a/client/world/hit_geom.go b/client/world/hit_geom.go deleted file mode 100644 index 0a41634..0000000 --- a/client/world/hit_geom.go +++ /dev/null @@ -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 -} diff --git a/client/world/hit_primitives.go b/client/world/hit_primitives.go deleted file mode 100644 index 3f91e90..0000000 --- a/client/world/hit_primitives.go +++ /dev/null @@ -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} -} diff --git a/client/world/hit_test.go b/client/world/hit_test.go index b3fa30d..c76ade8 100644 --- a/client/world/hit_test.go +++ b/client/world/hit_test.go @@ -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, ¶ms, 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) + }) + } +} diff --git a/client/world/indexing_test.go b/client/world/indexing_test.go deleted file mode 100644 index 176de9e..0000000 --- a/client/world/indexing_test.go +++ /dev/null @@ -1,1056 +0,0 @@ -package world - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -type gridCell struct { - Row int - Col int -} - -func newTestWorld(wReal, hReal int) *World { - w := NewWorld(wReal, hReal) - w.SetCircleRadiusScaleFp(SCALE) - return w -} - -func countObjectInGrid(g *World, id PrimitiveID) int { - count := 0 - for row := range g.grid { - for col := range g.grid[row] { - for _, item := range g.grid[row][col] { - if item.ID() == id { - count++ - } - } - } - } - return count -} - -func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool { - for _, item := range g.grid[row][col] { - if item.ID() == id { - return true - } - } - return false -} - -func TestViewportPxToWorldFixed(t *testing.T) { - tests := []struct { - name string - viewportWidthPx int - viewportHeightPx int - cameraZoom int - wantWidth int - wantHeight int - }{ - { - name: "zoom 1.0", - viewportWidthPx: 500, - viewportHeightPx: 400, - cameraZoom: SCALE, - wantWidth: 500 * SCALE, - wantHeight: 400 * SCALE, - }, - { - name: "zoom 2.0", - viewportWidthPx: 500, - viewportHeightPx: 400, - cameraZoom: 2 * SCALE, - wantWidth: 250 * SCALE, - wantHeight: 200 * SCALE, - }, - { - name: "zoom below 1.0", - viewportWidthPx: 550, - viewportHeightPx: 550, - cameraZoom: 917, - wantWidth: 599781, - wantHeight: 599781, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotW, gotH := viewportPxToWorldFixed(tt.viewportWidthPx, tt.viewportHeightPx, tt.cameraZoom) - require.Equal(t, tt.wantWidth, gotW) - require.Equal(t, tt.wantHeight, gotH) - }) - } -} - -func TestSplitByWrap_ZeroOrNegativeSizeReturnsNil(t *testing.T) { - tests := []struct { - name string - minX, maxX int - minY, maxY int - }{ - { - name: "zero width", - minX: 100, maxX: 100, - minY: 50, maxY: 100, - }, - { - name: "zero height", - minX: 100, maxX: 200, - minY: 50, maxY: 50, - }, - { - name: "negative width", - minX: 200, maxX: 100, - minY: 50, maxY: 100, - }, - { - name: "negative height", - minX: 100, maxX: 200, - minY: 100, maxY: 50, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rects := splitByWrap(600, 400, tt.minX, tt.maxX, tt.minY, tt.maxY) - require.Nil(t, rects) - }) - } -} - -func TestSplitByWrap_XWrapUsesWorldWidth(t *testing.T) { - rects := splitByWrap( - 600, 400, - 500, 650, - 50, 100, - ) - - require.Len(t, rects, 2) - require.Equal(t, Rect{minX: 500, maxX: 600, minY: 50, maxY: 100}, rects[0]) - require.Equal(t, Rect{minX: 0, maxX: 50, minY: 50, maxY: 100}, rects[1]) -} - -func TestSplitByWrap_YWrapUsesWorldHeight(t *testing.T) { - rects := splitByWrap( - 600, 400, - 50, 100, - 350, 450, - ) - - require.Len(t, rects, 2) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 350, maxY: 400}, rects[0]) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 50}, rects[1]) -} - -func TestSplitByWrap_XAndYWrap(t *testing.T) { - rects := splitByWrap( - 600, 400, - 550, 650, - 350, 450, - ) - - require.Len(t, rects, 4) - require.ElementsMatch(t, []Rect{ - {minX: 550, maxX: 600, minY: 350, maxY: 400}, - {minX: 550, maxX: 600, minY: 0, maxY: 50}, - {minX: 0, maxX: 50, minY: 350, maxY: 400}, - {minX: 0, maxX: 50, minY: 0, maxY: 50}, - }, rects) -} - -func TestSplitByWrap_NoWrapInsideWorld(t *testing.T) { - rects := splitByWrap( - 600, 400, - 100, 200, - 50, 100, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 100, maxX: 200, minY: 50, maxY: 100}, rects[0]) -} - -func TestSplitByWrap_FullWorldCoverageOnEqualWidth(t *testing.T) { - rects := splitByWrap( - 600, 400, - 0, 600, - 50, 100, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 0, maxX: 600, minY: 50, maxY: 100}, rects[0]) -} - -func TestSplitByWrap_FullWorldCoverageOnEqualHeight(t *testing.T) { - rects := splitByWrap( - 600, 400, - 50, 100, - 0, 400, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 400}, rects[0]) -} - -func TestSplitByWrap_FullWorldCoverageOnBothAxes(t *testing.T) { - rects := splitByWrap( - 600, 400, - 0, 600, - 0, 400, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 0, maxX: 600, minY: 0, maxY: 400}, rects[0]) -} - -func TestWorldToCell(t *testing.T) { - tests := []struct { - name string - value int - worldSize int - cells int - cellSize int - want int - }{ - { - name: "simple inside world", - value: 150, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 1, - }, - { - name: "negative wraps to last cell", - value: -1, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 5, - }, - { - name: "exact world size wraps to zero", - value: 600, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 0, - }, - { - name: "large positive wraps correctly", - value: 650, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 0, - }, - { - name: "last in-range value lands in last cell", - value: 599, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 5, - }, - {name: "first cell", value: 0, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, - {name: "middle cell", value: 2500, worldSize: 10000, cells: 5, cellSize: 2000, want: 1}, - {name: "last exact world point wraps to zero", value: 10000, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, - {name: "negative wraps to last", value: -1, worldSize: 10000, cells: 5, cellSize: 2000, want: 4}, - {name: "partial last cell is clamped", value: 9999, worldSize: 10000, cells: 4, cellSize: 3000, want: 3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := worldToCell(tt.value, tt.worldSize, tt.cells, tt.cellSize) - require.Equal(t, tt.want, got) - }) - } -} - -func TestResetGrid_UsesWidthForColsAndHeightForRows(t *testing.T) { - g := newTestWorld(600, 400) - - g.resetGrid(100 * SCALE) - - require.Equal(t, 6, g.cols) - require.Equal(t, 4, g.rows) - require.Len(t, g.grid, 4) - require.Len(t, g.grid[0], 6) -} - -func TestIndexPoint(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: 150 * SCALE, - Y: 250 * SCALE, - } - - g.indexObject(p) - - require.True(t, hasObjectInCell(g, 2, 1, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: -1, - Y: -1, - } - - g.indexObject(p) - - require.True(t, hasObjectInCell(g, 5, 5, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 50 * SCALE, - Y: 50 * SCALE, - Radius: 75 * SCALE, - } - - g.indexObject(c) - - // The circle spans [-25..125] on both axes. - // It must appear both near zero and near the wrapped end. - require.True(t, hasObjectInCell(g, 0, 0, id)) - require.True(t, hasObjectInCell(g, 0, 5, id)) - require.True(t, hasObjectInCell(g, 5, 0, id)) - require.True(t, hasObjectInCell(g, 5, 5, id)) - - // It also extends into the next cells near the origin. - require.True(t, hasObjectInCell(g, 0, 1, id)) - require.True(t, hasObjectInCell(g, 1, 0, id)) - require.True(t, hasObjectInCell(g, 1, 1, id)) -} - -func TestIndexCircle_NoWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - } - - g.indexObject(c) - - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.True(t, hasObjectInCell(g, 2, 3, id)) - require.True(t, hasObjectInCell(g, 3, 2, id)) - require.True(t, hasObjectInCell(g, 3, 3, id)) -} - -func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 400 * SCALE, - } - - g.indexObject(c) - - for row := 0; row < g.rows; row++ { - for col := 0; col < g.cols; col++ { - require.Truef(t, hasObjectInCell(g, row, col, id), "missing object in row=%d col=%d", row, col) - } - } -} - -func TestIndexLine_HorizontalWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 590 * SCALE, - Y1: 200 * SCALE, - X2: 10 * SCALE, - Y2: 200 * SCALE, - } - - g.indexObject(l) - - // The shortest torus representation crosses the right/left border. - require.True(t, hasObjectInCell(g, 2, 5, id)) - require.True(t, hasObjectInCell(g, 2, 0, id)) -} - -func TestIndexLine_VerticalWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 590 * SCALE, - X2: 200 * SCALE, - Y2: 10 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 5, 2, id)) - require.True(t, hasObjectInCell(g, 0, 2, id)) -} - -func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 590 * SCALE, - Y1: 590 * SCALE, - X2: 10 * SCALE, - Y2: 10 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 5, 5, id)) - require.True(t, hasObjectInCell(g, 0, 0, id)) -} - -func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 100 * SCALE, - Y1: 200 * SCALE, - X2: 300 * SCALE, - Y2: 200 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is half-open: [100,300). - // Therefore it occupies columns 1 and 2, but not column 3. - require.True(t, hasObjectInCell(g, 2, 1, id)) - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.False(t, hasObjectInCell(g, 2, 3, id)) -} - -func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 200 * SCALE, - Y2: 300 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is half-open: [100,300). - // Therefore it occupies rows 1 and 2, but not row 3. - require.True(t, hasObjectInCell(g, 1, 2, id)) - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.False(t, hasObjectInCell(g, 3, 2, id)) -} - -func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 250 * SCALE, - Y1: 350 * SCALE, - X2: 250 * SCALE, - Y2: 350 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 3, 2, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 400 * SCALE, - Y2: 100 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is [200,400), so it must occupy columns 2 and 3 only. - require.True(t, hasObjectInCell(g, 1, 2, id)) - require.True(t, hasObjectInCell(g, 1, 3, id)) - require.False(t, hasObjectInCell(g, 1, 4, id)) -} - -func collectOccupiedCells(g *World, id PrimitiveID) []gridCell { - var cells []gridCell - for row := range g.grid { - for col := range g.grid[row] { - for _, item := range g.grid[row][col] { - if item.ID() == id { - cells = append(cells, gridCell{Row: row, Col: col}) - break - } - } - } - } - return cells -} - -func allGridCells(rows, cols int) []gridCell { - cells := make([]gridCell, 0, rows*cols) - for row := 0; row < rows; row++ { - for col := 0; col < cols; col++ { - cells = append(cells, gridCell{Row: row, Col: col}) - } - } - return cells -} - -func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) { - t.Helper() - - got := collectOccupiedCells(g, id) - - require.ElementsMatchf( - t, - want, - got, - "unexpected indexed cells for object %d", - id, - ) -} - -func TestIndexObject_Point_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Point - wantCells []gridCell - }{ - { - name: "point inside world", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 150 * SCALE, - Y: 250 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 1}, - }, - }, - { - name: "point wraps from negative coordinates to last cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: -1, - Y: -1, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - }, - }, - { - name: "point exactly at world boundary wraps to zero cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 600 * SCALE, - Y: 600 * SCALE, - }, - wantCells: []gridCell{ - {Row: 0, Col: 0}, - }, - }, - { - name: "point on cell boundary belongs to that cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 200 * SCALE, - Y: 300 * SCALE, - }, - wantCells: []gridCell{ - {Row: 3, Col: 2}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -func TestIndexObject_Circle_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Circle - wantCells []gridCell - }{ - { - name: "circle without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 2}, - {Row: 2, Col: 3}, - {Row: 3, Col: 2}, - {Row: 3, Col: 3}, - }, - }, - { - name: "circle wraps across left and top edges", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 50 * SCALE, - Y: 50 * SCALE, - Radius: 75 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - {Row: 5, Col: 0}, - {Row: 5, Col: 1}, - {Row: 0, Col: 5}, - {Row: 0, Col: 0}, - {Row: 0, Col: 1}, - {Row: 1, Col: 5}, - {Row: 1, Col: 0}, - {Row: 1, Col: 1}, - }, - }, - { - name: "circle wraps across right edge only", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 575 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 5}, - {Row: 2, Col: 0}, - {Row: 3, Col: 5}, - {Row: 3, Col: 0}, - }, - }, - { - name: "circle wraps across bottom edge only", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 575 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 2}, - {Row: 5, Col: 3}, - {Row: 0, Col: 2}, - {Row: 0, Col: 3}, - }, - }, - { - name: "circle larger than world covers the whole grid", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 400 * SCALE, - }, - wantCells: allGridCells(6, 6), - }, - { - name: "circle touching boundaries exactly uses half-open indexing", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 100 * SCALE, // bbox [200, 400) x [200, 400) - }, - wantCells: []gridCell{ - {Row: 2, Col: 2}, - {Row: 2, Col: 3}, - {Row: 3, Col: 2}, - {Row: 3, Col: 3}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -func TestIndexObject_Line_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Line - wantCells []gridCell - }{ - { - name: "horizontal line without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 100 * SCALE, - Y1: 200 * SCALE, - X2: 300 * SCALE, - Y2: 200 * SCALE, - }, - // Half-open interval [100,300), so only cols 1 and 2. - wantCells: []gridCell{ - {Row: 2, Col: 1}, - {Row: 2, Col: 2}, - }, - }, - { - name: "vertical line without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 200 * SCALE, - Y2: 300 * SCALE, - }, - // Half-open interval [100,300), so only rows 1 and 2. - wantCells: []gridCell{ - {Row: 1, Col: 2}, - {Row: 2, Col: 2}, - }, - }, - { - name: "horizontal line wraps across left right border", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 590 * SCALE, - Y1: 200 * SCALE, - X2: 10 * SCALE, - Y2: 200 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 5}, - {Row: 2, Col: 0}, - }, - }, - { - name: "vertical line wraps across top bottom border", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 590 * SCALE, - X2: 200 * SCALE, - Y2: 10 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 2}, - {Row: 0, Col: 2}, - }, - }, - { - name: "diagonal line wraps across both axes", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 590 * SCALE, - Y1: 590 * SCALE, - X2: 10 * SCALE, - Y2: 10 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - {Row: 5, Col: 0}, - {Row: 0, Col: 5}, - {Row: 0, Col: 0}, - }, - }, - { - name: "zero length line indexes a single cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 250 * SCALE, - Y1: 350 * SCALE, - X2: 250 * SCALE, - Y2: 350 * SCALE, - }, - wantCells: []gridCell{ - {Row: 3, Col: 2}, - }, - }, - { - name: "line exactly on cell boundaries follows half-open interval", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 400 * SCALE, - Y2: 100 * SCALE, - }, - // [200,400) => cols 2 and 3 only. - wantCells: []gridCell{ - {Row: 1, Col: 2}, - {Row: 1, Col: 3}, - }, - }, - { - name: "diagonal line without wrap indexes its full bbox footprint", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 100 * SCALE, - Y1: 100 * SCALE, - X2: 300 * SCALE, - Y2: 300 * SCALE, - }, - // Indexing is bbox-based, not raster-based. - // The bbox is [100,300) x [100,300), so four cells. - wantCells: []gridCell{ - {Row: 1, Col: 1}, - {Row: 1, Col: 2}, - {Row: 2, Col: 1}, - {Row: 2, Col: 2}, - }, - }, - { - name: "horizontal wrap exactly on borders still indexes both edge cells", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 600 * SCALE, - Y1: 100 * SCALE, - X2: 0, - Y2: 100 * SCALE, - }, - // After wrapping both endpoints are equivalent to zero-width on the edge. - // The degenerate bbox expansion should still index the first cell only. - wantCells: []gridCell{ - {Row: 1, Col: 0}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) { - g := newTestWorld(600, 400) - - pID := PrimitiveID(1) - cID := PrimitiveID(2) - lID := PrimitiveID(3) - - g.objects[pID] = Point{ - Id: pID, - X: 50 * SCALE, - Y: 50 * SCALE, - } - g.objects[cID] = Circle{ - Id: cID, - X: 300 * SCALE, - Y: 200 * SCALE, - Radius: 50 * SCALE, - } - g.objects[lID] = Line{ - Id: lID, - X1: 590 * SCALE, - Y1: 100 * SCALE, - X2: 10 * SCALE, - Y2: 100 * SCALE, - } - - g.IndexOnViewportChange(500, 300, 1.) - - require.Greater(t, g.cellSize, 0) - require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) - require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) - - require.Greaterf(t, countObjectInGrid(g, pID), 0, "point %s was not indexed", pID) - require.Greaterf(t, countObjectInGrid(g, cID), 0, "circle %s was not indexed", cID) - require.Greaterf(t, countObjectInGrid(g, lID), 0, "line %s was not indexed", lID) -} - -func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) { - g := newTestWorld(600, 400) - g.IndexOnViewportChange(500, 300, 1.) - - require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) - require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) - require.Len(t, g.grid, g.rows) - require.Len(t, g.grid[0], g.cols) -} - -func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) { - g := newTestWorld(600, 600) - - id := PrimitiveID(1) - g.objects[id] = Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - } - - g.IndexOnViewportChange(500, 500, 1.) - firstCellSize := g.cellSize - firstCount := countObjectInGrid(g, id) - - g.IndexOnViewportChange(200, 200, 1.) - secondCellSize := g.cellSize - secondCount := countObjectInGrid(g, id) - - require.NotEqual(t, firstCellSize, secondCellSize) - require.Greater(t, firstCount, 0) - require.Greater(t, secondCount, 0) - - if firstCellSize != secondCellSize && firstCount == secondCount { - t.Logf( - "cell size changed from %d to %d, but the indexed cell count happened to stay equal (%d)", - firstCellSize, - secondCellSize, - firstCount, - ) - } -} - -func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: 100 * SCALE, - Y: 100 * SCALE, - } - - g.indexObject(p) - - got := collectOccupiedCells(g, id) - require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id)) -} diff --git a/client/world/options.go b/client/world/options.go deleted file mode 100644 index 2b79220..0000000 --- a/client/world/options.go +++ /dev/null @@ -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 } -} diff --git a/client/world/pivot_zoom.go b/client/world/pivot_zoom.go deleted file mode 100644 index 7fdb755..0000000 --- a/client/world/pivot_zoom.go +++ /dev/null @@ -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 -} diff --git a/client/world/pivot_zoom_test.go b/client/world/pivot_zoom_test.go deleted file mode 100644 index 6711116..0000000 --- a/client/world/pivot_zoom_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/primitive.go b/client/world/primitive.go deleted file mode 100644 index b3e3ff6..0000000 --- a/client/world/primitive.go +++ /dev/null @@ -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 } diff --git a/client/world/primitive_test.go b/client/world/primitive_test.go deleted file mode 100644 index 86a11df..0000000 --- a/client/world/primitive_test.go +++ /dev/null @@ -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) - } -} diff --git a/client/world/render_priority_test.go b/client/world/render_priority_test.go deleted file mode 100644 index 8e88200..0000000 --- a/client/world/render_priority_test.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer.go b/client/world/renderer.go index 541ee94..5f081d1 100644 --- a/client/world/renderer.go +++ b/client/world/renderer.go @@ -2,7 +2,10 @@ package world import ( "errors" + "image" "image/color" + "sort" + "sync" "time" ) @@ -278,7 +281,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { return nil } - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) if err != nil { return err } @@ -337,7 +340,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { return nil } - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) if err != nil { return err } @@ -361,7 +364,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { } // --- Full redraw path --- - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) if err != nil { return err } @@ -474,10 +477,12 @@ func tileWorldRectNoWrap(worldRect Rect, W, H int) []WorldTile { } } +// isEmptyRectPx reports whether r covers no canvas pixels. func isEmptyRectPx(r RectPx) bool { return r.W <= 0 || r.H <= 0 } +// intersectRectPx returns the intersection of two half-open canvas rectangles. func intersectRectPx(a, b RectPx) (RectPx, bool) { ax2 := a.X + a.W ay2 := a.Y + a.H @@ -497,3 +502,1388 @@ func intersectRectPx(a, b RectPx) (RectPx, bool) { return RectPx{X: x1, Y: y1, W: w, H: h}, true } + +// 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) +} + +// buildRenderPlan builds a full expanded-canvas redraw plan. +// +// 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) buildRenderPlan(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 +} + +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 +} + +// drawKind is used only for stable tie-breaking when priorities are equal. +type drawKind int + +const ( + drawKindLine drawKind = iota + drawKindCircle + drawKindPoint +) + +// drawItem is the normalized per-tile render record used for stable ordering. +// +// Each instance stores exactly one primitive payload together with the sort key +// that drawPlanSinglePass uses before issuing final drawer commands. +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] + } +} + +// 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() +} + +var ( + errInvalidCanvasSize = errors.New("incremental: invalid canvas size") +) + +// IncrementalMode describes how the renderer should update the backing image. +type IncrementalMode int + +const ( + // IncrementalNoOp means no visual change is needed (dx=0 and dy=0). + IncrementalNoOp IncrementalMode = iota + + // IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn. + IncrementalShift + + // IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw. + IncrementalFullRedraw +) + +// RectPx is an integer rectangle in canvas pixel coordinates. +// Semantics are half-open: [X, X+W) x [Y, Y+H). +type RectPx struct { + X, Y int + W, H int +} + +// IncrementalPolicy is a placeholder for future incremental tuning. +// It is intentionally not used in C2; we only fix geometry-based thresholding now. +type IncrementalPolicy struct { + // CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates). + // This will be implemented later; kept here as a placeholder to lock the API shape. + CoalesceUpdates bool + + // AllowShiftOnly allows a temporary mode where the backing image is shifted + // but dirty rects are not redrawn immediately under overload. + AllowShiftOnly bool + + // RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation. + RenderBudgetMs int + + // MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame. + // 0 means "no limit". + MaxCatchUpAreaPx int +} + +// IncrementalPlan is the output of pure incremental planning. +// It does not perform any drawing. It only describes what should happen. +type IncrementalPlan struct { + Mode IncrementalMode + + // Shift to apply to the backing image in canvas pixels. + // Positive dx shifts the existing image to the right (exposing a dirty strip on the left). + // Positive dy shifts the existing image down (exposing a dirty strip on the top). + DxPx int + DyPx int + + // Dirty rects to redraw after shifting (in canvas pixel coordinates). + // Rects may overlap; overlapping is allowed and simplifies planning. + Dirty []RectPx +} + +// PlanIncrementalPan computes whether the renderer can update by shifting the backing image +// and redrawing only exposed strips, or must fall back to a full redraw. +// +// Threshold rule (per-axis): +// - If abs(dxPx) > marginXPx/2 => full redraw +// - If abs(dyPx) > marginYPx/2 => full redraw +// +// Additional safety rules: +// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw +// +// Returned dirty rects follow the chosen shift direction: +// +// dxPx > 0 => dirty strip on the left (width=dxPx) +// dxPx < 0 => dirty strip on the right (width=-dxPx) +// dyPx > 0 => dirty strip on the top (height=dyPx) +// dyPx < 0 => dirty strip on the bottom(height=-dyPx) +func PlanIncrementalPan( + canvasW, canvasH int, + marginXPx, marginYPx int, + dxPx, dyPx int, +) (IncrementalPlan, error) { + if canvasW <= 0 || canvasH <= 0 { + return IncrementalPlan{}, errInvalidCanvasSize + } + if marginXPx < 0 || marginYPx < 0 { + return IncrementalPlan{}, errors.New("incremental: invalid margins") + } + + // No movement => no work. + if dxPx == 0 && dyPx == 0 { + return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil + } + + adx := abs(dxPx) + ady := abs(dyPx) + + // Too large shift can’t be represented as "shift + stripes". + if adx >= canvasW || ady >= canvasH { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + // Thresholds: per axis, independently. + // Using integer division: margin/2 truncates down, which is fine and deterministic. + thrX := marginXPx / 2 + thrY := marginYPx / 2 + + if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + // If margin is 0, thr is 0, and any non-zero delta should force full redraw + // (because we have no buffer area to shift into). + if marginXPx == 0 && dxPx != 0 { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + if marginYPx == 0 && dyPx != 0 { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + dirty := make([]RectPx, 0, 2) + + // Horizontal exposed strip with 1px overdraw to avoid seams. + if dxPx > 0 { + // Image moved right => left strip is exposed. + w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area + dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH}) + } else if dxPx < 0 { + // Image moved left => right strip is exposed. + w := min((-dxPx)+1, canvasW) + dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH}) + } + + // Vertical exposed strip with 1px overdraw to avoid seams. + if dyPx > 0 { + // Image moved down => top strip is exposed. + h := min(dyPx+1, canvasH) + dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h}) + } else if dyPx < 0 { + // Image moved up => bottom strip is exposed. + h := min((-dyPx)+1, canvasH) + dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h}) + } + + // Filter out any zero/negative rects defensively. + out := dirty[:0] + for _, r := range dirty { + if r.W <= 0 || r.H <= 0 { + continue + } + out = append(out, r) + } + + return IncrementalPlan{ + Mode: IncrementalShift, + DxPx: dxPx, + DyPx: dyPx, + Dirty: out, + }, nil +} + +// shiftAndClipRectPx moves r by the supplied pixel delta and clips it to the +// current canvas bounds. +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 +} + +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 +} + +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() +} + +// drawBackgroundOne draws one background-image instance at the requested +// canvas position, scaling it when needed. +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 +} + +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 +} + +// RenderScheduler is a toolkit-agnostic example of render-request coalescing. +// +// It keeps at most one render in flight and always collapses intermediate +// requests to the latest RenderParams snapshot. The scheduler is intentionally +// not a background renderer: real UI integrations must still execute World.Render +// on the UI thread and should replace the goroutine hand-off in runOnUIThread +// with toolkit-specific scheduling primitives. +// RenderScheduler keeps the latest requested RenderParams and serializes renders. +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 renders the latest known params and repeats if newer params +// arrived while the previous render was running. +// +// The example body uses a goroutine only as a placeholder. Real applications +// should run the body on their UI event loop. +func (s *RenderScheduler) runOnUIThread() { + for { + s.mu.Lock() + params := s.latest + s.mu.Unlock() + + s.w.ClampRenderParamsNoWrap(¶ms) + _ = 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() + } +} + +// 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, + } +} + +// DefaultIncrementalPolicy returns the default incremental pan policy. +// +// The zero-friction default is conservative: no shift-only degradation, no +// render-budget heuristics, and no catch-up area cap. +func DefaultIncrementalPolicy() IncrementalPolicy { + return IncrementalPolicy{ + CoalesceUpdates: false, + AllowShiftOnly: false, + RenderBudgetMs: 0, + MaxCatchUpAreaPx: 0, + } +} + +// 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) +} diff --git a/client/world/renderer_background.go b/client/world/renderer_background.go deleted file mode 100644 index f154aff..0000000 --- a/client/world/renderer_background.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_background_offset_scale_test.go b/client/world/renderer_background_offset_scale_test.go deleted file mode 100644 index f698a33..0000000 --- a/client/world/renderer_background_offset_scale_test.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_background_offset_test.go b/client/world/renderer_background_offset_test.go deleted file mode 100644 index 856bfb5..0000000 --- a/client/world/renderer_background_offset_test.go +++ /dev/null @@ -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")) -} diff --git a/client/world/renderer_background_scale_test.go b/client/world/renderer_background_scale_test.go deleted file mode 100644 index 1a6d796..0000000 --- a/client/world/renderer_background_scale_test.go +++ /dev/null @@ -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")) -} diff --git a/client/world/renderer_background_test.go b/client/world/renderer_background_test.go deleted file mode 100644 index 0ea291f..0000000 --- a/client/world/renderer_background_test.go +++ /dev/null @@ -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")) -} diff --git a/client/world/renderer_bench_test.go b/client/world/renderer_bench_test.go new file mode 100644 index 0000000..b5514cc --- /dev/null +++ b/client/world/renderer_bench_test.go @@ -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) + } + } +} diff --git a/client/world/renderer_circles.go b/client/world/renderer_circles.go deleted file mode 100644 index 59ba7ad..0000000 --- a/client/world/renderer_circles.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_circles_test.go b/client/world/renderer_circles_test.go deleted file mode 100644 index faa9899..0000000 --- a/client/world/renderer_circles_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/renderer_circles_wrap_test.go b/client/world/renderer_circles_wrap_test.go deleted file mode 100644 index 9c7a060..0000000 --- a/client/world/renderer_circles_wrap_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/client/world/renderer_draw.go b/client/world/renderer_draw.go deleted file mode 100644 index 36eb6b2..0000000 --- a/client/world/renderer_draw.go +++ /dev/null @@ -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] - } -} diff --git a/client/world/renderer_draw_lines_bench_test.go b/client/world/renderer_draw_lines_bench_test.go deleted file mode 100644 index 6b9759c..0000000 --- a/client/world/renderer_draw_lines_bench_test.go +++ /dev/null @@ -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.) -} diff --git a/client/world/renderer_draw_primitives.go b/client/world/renderer_draw_primitives.go deleted file mode 100644 index a5b384f..0000000 --- a/client/world/renderer_draw_primitives.go +++ /dev/null @@ -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() -} diff --git a/client/world/renderer_drawitems_reuse_bench_test.go b/client/world/renderer_drawitems_reuse_bench_test.go deleted file mode 100644 index c372781..0000000 --- a/client/world/renderer_drawitems_reuse_bench_test.go +++ /dev/null @@ -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) - } -} diff --git a/client/world/renderer_helper.go b/client/world/renderer_helper.go deleted file mode 100644 index d7f66d3..0000000 --- a/client/world/renderer_helper.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_incremental_budget_test.go b/client/world/renderer_incremental_budget_test.go deleted file mode 100644 index f0efd73..0000000 --- a/client/world/renderer_incremental_budget_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_incremental_catchup_limit_test.go b/client/world/renderer_incremental_catchup_limit_test.go deleted file mode 100644 index e5371d5..0000000 --- a/client/world/renderer_incremental_catchup_limit_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_incremental_plan.go b/client/world/renderer_incremental_plan.go deleted file mode 100644 index bd2245e..0000000 --- a/client/world/renderer_incremental_plan.go +++ /dev/null @@ -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 can’t be represented as "shift + stripes". - if adx >= canvasW || ady >= canvasH { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - // Thresholds: per axis, independently. - // Using integer division: margin/2 truncates down, which is fine and deterministic. - thrX := marginXPx / 2 - thrY := marginYPx / 2 - - if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - // If margin is 0, thr is 0, and any non-zero delta should force full redraw - // (because we have no buffer area to shift into). - if marginXPx == 0 && dxPx != 0 { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - if marginYPx == 0 && dyPx != 0 { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - dirty := make([]RectPx, 0, 2) - - // Horizontal exposed strip with 1px overdraw to avoid seams. - if dxPx > 0 { - // Image moved right => left strip is exposed. - w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area - dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH}) - } else if dxPx < 0 { - // Image moved left => right strip is exposed. - w := min((-dxPx)+1, canvasW) - dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH}) - } - - // Vertical exposed strip with 1px overdraw to avoid seams. - if dyPx > 0 { - // Image moved down => top strip is exposed. - h := min(dyPx+1, canvasH) - dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h}) - } else if dyPx < 0 { - // Image moved up => bottom strip is exposed. - h := min((-dyPx)+1, canvasH) - dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h}) - } - - // Filter out any zero/negative rects defensively. - out := dirty[:0] - for _, r := range dirty { - if r.W <= 0 || r.H <= 0 { - continue - } - out = append(out, r) - } - - return IncrementalPlan{ - Mode: IncrementalShift, - DxPx: dxPx, - DyPx: dyPx, - Dirty: out, - }, nil -} - -func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) { - n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H} - inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH}) - return inter, ok -} - -// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries -// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own -// TileDrawPlan entry with the clip replaced by the intersection. -// -// This makes drawing functions naturally render only the dirty areas. -func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan { - if len(dirty) == 0 { - return RenderPlan{ - CanvasWidthPx: plan.CanvasWidthPx, - CanvasHeightPx: plan.CanvasHeightPx, - ZoomFp: plan.ZoomFp, - WorldRect: plan.WorldRect, - Tiles: nil, - } - } - - outTiles := make([]TileDrawPlan, 0) - - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH} - - for _, dr := range dirty { - if isEmptyRectPx(dr) { - continue - } - - inter, ok := intersectRectPx(tileClip, dr) - if !ok { - continue - } - - outTiles = append(outTiles, TileDrawPlan{ - Tile: td.Tile, - ClipX: inter.X, - ClipY: inter.Y, - ClipW: inter.W, - ClipH: inter.H, - Candidates: td.Candidates, - }) - } - } - - return RenderPlan{ - CanvasWidthPx: plan.CanvasWidthPx, - CanvasHeightPx: plan.CanvasHeightPx, - ZoomFp: plan.ZoomFp, - WorldRect: plan.WorldRect, - Tiles: outTiles, - } -} - -// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx. -// It returns (selected, remaining). If maxAreaPx <= 0, it selects all. -func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) { - if len(pending) == 0 { - return nil, nil - } - if maxAreaPx <= 0 { - // No limit. - all := append([]RectPx(nil), pending...) - return all, nil - } - - selected = make([]RectPx, 0, len(pending)) - remaining = make([]RectPx, 0) - - used := 0 - for _, r := range pending { - if r.W <= 0 || r.H <= 0 { - continue - } - area := r.W * r.H - if area <= 0 { - continue - } - - // If we cannot fit the whole rect, we stop (simple, deterministic). - // (We do not split rectangles here to keep logic simple.) - if used+area > maxAreaPx { - remaining = append(remaining, r) - continue - } - - selected = append(selected, r) - used += area - } - - // Also keep any rects we skipped due to invalid size (none) and those that didn't fit. - // Note: remaining preserves original order among non-selected entries. - return selected, remaining -} diff --git a/client/world/renderer_incremental_plan_test.go b/client/world/renderer_incremental_plan_test.go deleted file mode 100644 index 05ee925..0000000 --- a/client/world/renderer_incremental_plan_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_incremental_render_test.go b/client/world/renderer_incremental_render_test.go deleted file mode 100644 index a618c3a..0000000 --- a/client/world/renderer_incremental_render_test.go +++ /dev/null @@ -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")) -} diff --git a/client/world/renderer_incremental_state.go b/client/world/renderer_incremental_state.go deleted file mode 100644 index 8e85d87..0000000 --- a/client/world/renderer_incremental_state.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_incremental_state_test.go b/client/world/renderer_incremental_state_test.go deleted file mode 100644 index 84ae1cf..0000000 --- a/client/world/renderer_incremental_state_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_lines_test.go b/client/world/renderer_lines_test.go deleted file mode 100644 index 64fc013..0000000 --- a/client/world/renderer_lines_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_plan.go b/client/world/renderer_plan.go deleted file mode 100644 index de3541d..0000000 --- a/client/world/renderer_plan.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_plan_bench_test.go b/client/world/renderer_plan_bench_test.go deleted file mode 100644 index c894611..0000000 --- a/client/world/renderer_plan_bench_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/client/world/renderer_points.go b/client/world/renderer_points.go deleted file mode 100644 index 5218f4f..0000000 --- a/client/world/renderer_points.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_points_test.go b/client/world/renderer_points_test.go deleted file mode 100644 index b4bdd7d..0000000 --- a/client/world/renderer_points_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/renderer_points_wrap_test.go b/client/world/renderer_points_wrap_test.go deleted file mode 100644 index e4dc084..0000000 --- a/client/world/renderer_points_wrap_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/client/world/renderer_query.go b/client/world/renderer_query.go deleted file mode 100644 index 739579c..0000000 --- a/client/world/renderer_query.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_render_test.go b/client/world/renderer_render_test.go deleted file mode 100644 index 5435590..0000000 --- a/client/world/renderer_render_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/renderer_schedule.go b/client/world/renderer_schedule.go deleted file mode 100644 index cf32ecb..0000000 --- a/client/world/renderer_schedule.go +++ /dev/null @@ -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(¶ms) - _ = 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() - } -} diff --git a/client/world/renderer_smoke_mixed_test.go b/client/world/renderer_smoke_mixed_test.go deleted file mode 100644 index 12b89ae..0000000 --- a/client/world/renderer_smoke_mixed_test.go +++ /dev/null @@ -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") -} diff --git a/client/world/renderer_style.go b/client/world/renderer_style.go deleted file mode 100644 index 63a8a61..0000000 --- a/client/world/renderer_style.go +++ /dev/null @@ -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, - } -} diff --git a/client/world/renderer_style_application_test.go b/client/world/renderer_style_application_test.go deleted file mode 100644 index cd76c23..0000000 --- a/client/world/renderer_style_application_test.go +++ /dev/null @@ -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 -} diff --git a/client/world/renderer_style_apply.go b/client/world/renderer_style_apply.go deleted file mode 100644 index 97875e0..0000000 --- a/client/world/renderer_style_apply.go +++ /dev/null @@ -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) -} diff --git a/client/world/renderer_test.go b/client/world/renderer_test.go index b967f3e..58345ff 100644 --- a/client/world/renderer_test.go +++ b/client/world/renderer_test.go @@ -1,12 +1,14 @@ package world import ( + "github.com/stretchr/testify/require" + "image" + "image/color" "sort" "testing" - - "github.com/stretchr/testify/require" ) +// TestRenderParamsCanvasSize verifies render Params Canvas Size. func TestRenderParamsCanvasSize(t *testing.T) { t.Parallel() @@ -21,6 +23,7 @@ func TestRenderParamsCanvasSize(t *testing.T) { require.Equal(t, 120, params.CanvasHeightPx()) } +// TestRenderParamsCameraZoomFp verifies render Params Camera Zoom Fp. func TestRenderParamsCameraZoomFp(t *testing.T) { t.Parallel() @@ -33,6 +36,7 @@ func TestRenderParamsCameraZoomFp(t *testing.T) { require.Equal(t, 1250, zoomFp) } +// TestRenderParamsExpandedCanvasWorldRect verifies render Params Expanded Canvas World Rect. func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) { t.Parallel() @@ -55,6 +59,7 @@ func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) { require.Equal(t, 100000, rect.maxY) } +// TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera verifies render Params Expanded Canvas World Rect Allows Out Of World Camera. func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) { t.Parallel() @@ -77,6 +82,7 @@ func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) require.Equal(t, 63000, rect.maxY) } +// TestRenderParamsValidate verifies render Params Validate. func TestRenderParamsValidate(t *testing.T) { t.Parallel() @@ -93,6 +99,7 @@ func TestRenderParamsValidate(t *testing.T) { require.NoError(t, params.Validate()) } +// TestRenderParamsValidateRejectsInvalidViewport verifies render Params Validate Rejects Invalid Viewport. func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) { t.Parallel() @@ -132,6 +139,7 @@ func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) { } } +// TestRenderParamsValidateRejectsInvalidMargins verifies render Params Validate Rejects Invalid Margins. func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) { t.Parallel() @@ -157,6 +165,7 @@ func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) { } } +// TestRenderParamsValidateRejectsInvalidCameraZoom verifies render Params Validate Rejects Invalid Camera Zoom. func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) { t.Parallel() @@ -189,6 +198,7 @@ func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) { } } +// TestExpandedCanvasWorldRect verifies expanded Canvas World Rect. func TestExpandedCanvasWorldRect(t *testing.T) { t.Parallel() @@ -204,6 +214,7 @@ func TestExpandedCanvasWorldRect(t *testing.T) { require.Equal(t, 100000, rect.maxY) } +// TestExpandedCanvasWorldRectPanics verifies expanded Canvas World Rect Panics. func TestExpandedCanvasWorldRectPanics(t *testing.T) { t.Parallel() @@ -220,6 +231,7 @@ func TestExpandedCanvasWorldRectPanics(t *testing.T) { }) } +// TestWorldRenderRejectsNilDrawer verifies world Render Rejects Nil Drawer. func TestWorldRenderRejectsNilDrawer(t *testing.T) { t.Parallel() @@ -236,6 +248,7 @@ func TestWorldRenderRejectsNilDrawer(t *testing.T) { require.ErrorIs(t, err, errNilDrawer) } +// TestWorldRenderRejectsInvalidParams verifies world Render Rejects Invalid Params. func TestWorldRenderRejectsInvalidParams(t *testing.T) { t.Parallel() @@ -252,6 +265,7 @@ func TestWorldRenderRejectsInvalidParams(t *testing.T) { require.ErrorIs(t, err, errInvalidViewportSize) } +// TestWorldRenderReturnsErrorWhenGridNotBuilt verifies world Render Returns Error When Grid Not Built. func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) { t.Parallel() @@ -270,6 +284,7 @@ func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) { require.ErrorIs(t, err, errGridNotBuilt) } +// TestWorldRenderStageAStubReturnsNilOnValidInput verifies world Render Stage A Stub Returns Nil On Valid Input. func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) { t.Parallel() @@ -292,6 +307,7 @@ func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) { require.NoError(t, err) } +// TestTileWorldRect_NoWrapSingleTile verifies tile World Rect No Wrap Single Tile. func TestTileWorldRect_NoWrapSingleTile(t *testing.T) { t.Parallel() @@ -311,6 +327,7 @@ func TestTileWorldRect_NoWrapSingleTile(t *testing.T) { require.Equal(t, 25, tiles[0].Rect.maxY) } +// TestTileWorldRect_WrapX_TwoTiles verifies tile World Rect Wrap X Two Tiles. func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) { t.Parallel() @@ -334,6 +351,7 @@ func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) { require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect) } +// TestTileWorldRect_WrapX_NegativeCoords verifies tile World Rect Wrap X Negative Coords. func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) { t.Parallel() @@ -357,6 +375,7 @@ func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) { require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect) } +// TestTileWorldRect_WrapXY_FourTiles verifies tile World Rect Wrap XY Four Tiles. func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) { t.Parallel() @@ -389,6 +408,7 @@ func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) { require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect) } +// TestTileWorldRect_EmptyRectReturnsNil verifies tile World Rect Empty Rect Returns Nil. func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) { t.Parallel() @@ -400,6 +420,7 @@ func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) { require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) } +// TestTileWorldRectPanicsOnInvalidWorldSize verifies tile World Rect Panics On Invalid World Size. func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) { t.Parallel() @@ -407,6 +428,7 @@ func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) { require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) }) } +// TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt verifies collect Candidates For Tiles Returns Error When Grid Not Built. func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) { t.Parallel() @@ -422,6 +444,7 @@ func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) { require.ErrorIs(t, err, errGridNotBuilt) } +// TestCollectCandidatesForTileDedupsWithinOneTile verifies collect Candidates For Tile Dedups Within One Tile. func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) { t.Parallel() @@ -443,6 +466,7 @@ func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) { require.Equal(t, id, items[0].ID()) } +// TestCollectCandidatesForTileReturnsPointInCoveredCell verifies collect Candidates For Tile Returns Point In Covered Cell. func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) { t.Parallel() @@ -467,6 +491,7 @@ func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) { require.Empty(t, items2) } +// TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides verifies collect Candidates For Tiles Wrap Indexed Circle Appears In Both Sides. func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) { t.Parallel() @@ -499,6 +524,7 @@ func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing require.Equal(t, id, batches[1].Items[0].ID()) } +// TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas verifies build Render Plan Stage A Single Tile Clip Is Whole Canvas. func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { t.Parallel() @@ -518,7 +544,7 @@ func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { CameraZoom: 2.0, } - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) require.NoError(t, err) require.Equal(t, 150, plan.CanvasWidthPx) @@ -537,6 +563,7 @@ func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { require.Equal(t, 0, td.Tile.OffsetY) } +// TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps verifies build Render Plan Stage A Tiles Cover Canvas Without Gaps. func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { t.Parallel() @@ -555,7 +582,7 @@ func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { CameraZoom: 1.0, } - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) require.NoError(t, err) require.Equal(t, 150, plan.CanvasWidthPx) @@ -634,6 +661,7 @@ func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { require.Equal(t, plan.CanvasHeightPx, cursorY) } +// TestBuildRenderPlanStageA_CandidatesArePerTileDeduped verifies build Render Plan Stage A Candidates Are Per Tile Deduped. func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { t.Parallel() @@ -655,7 +683,7 @@ func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { CameraZoom: 1.0, } - plan, err := w.buildRenderPlanStageA(params) + plan, err := w.buildRenderPlan(params) require.NoError(t, err) require.NotEmpty(t, plan.Tiles) @@ -673,6 +701,7 @@ func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { } } +// TestWorldForceFullRedrawNextResetsIncrementalState verifies world Force Full Redraw Next Resets Incremental State. func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) { t.Parallel() @@ -695,3 +724,2602 @@ func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) { w.ForceFullRedrawNext() require.False(t, w.renderState.initialized) } + +// TestRender_SortsByPriorityWithinTile verifies render Sorts By Priority Within Tile. +func TestRender_SortsByPriorityWithinTile(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Same tile. Priorities deliberately mixed. + _, err := w.AddCircle(5, 5, 1, CircleWithPriority(500)) + require.NoError(t, err) + + _, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100)) + require.NoError(t, err) + + _, err = w.AddPoint(5, 6, PointWithPriority(300)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + // default: wrap on + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + // We verify the first occurrence of each primitive kind follows priority order. + // Since each object is drawn with Add* + Fill/Stroke immediately, order should match. + cmds := d.Commands() + firstLine := indexOfFirst(cmds, "AddLine") + firstCircle := indexOfFirst(cmds, "AddCircle") + firstPoint := indexOfFirst(cmds, "AddPoint") + + require.NotEqual(t, -1, firstLine) + require.NotEqual(t, -1, firstCircle) + require.NotEqual(t, -1, firstPoint) + + require.Less(t, firstLine, firstPoint) + require.Less(t, firstPoint, firstCircle) // 300 before 500 +} + +func indexOfFirst(cmds []fakeDrawerCommand, name string) int { + for i, c := range cmds { + if c.Name == name { + return i + } + } + return -1 +} + +type bgOffsetScaleTheme struct { + img image.Image + anchor BackgroundAnchorMode +} + +func (t bgOffsetScaleTheme) ID() string { return "bgoffset" } +func (t bgOffsetScaleTheme) Name() string { return "bgoffset" } + +func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img } + +func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } +func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } +func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } + +func (t bgOffsetScaleTheme) PointStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} +} +func (t bgOffsetScaleTheme) LineStyle() Style { + return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} +func (t bgOffsetScaleTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +// TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan verifies render Background Tile Repeat World Anchored Shifts With Pan. +func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) { + t.Parallel() + + w := NewWorld(20, 20) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4 + w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld}) + + params := RenderParams{ + ViewportWidthPx: 8, + ViewportHeightPx: 8, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + // First render. + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, params)) + + minX1, minY1 := minDrawImageXY(t, d1) + require.Equal(t, -1, minX1) + require.Equal(t, -1, minY1) + + // Pan camera by +1 world unit along both axes (zoom=1 => 1px). + params2 := params + params2.CameraXWorldFp += 1 * SCALE + params2.CameraYWorldFp += 1 * SCALE + + // Force full redraw to make this test independent of incremental pipeline. + w.ForceFullRedrawNext() + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params2)) + + minX2, minY2 := minDrawImageXY(t, d2) + + // With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size). + require.Equal(t, -2, minX2) + require.Equal(t, -2, minY2) +} + +// TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan verifies render Background Tile Repeat Viewport Anchored Does Not Shift With Pan. +func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) { + t.Parallel() + + w := NewWorld(20, 20) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport}) + + params := RenderParams{ + ViewportWidthPx: 8, + ViewportHeightPx: 8, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, params)) + + minX1, minY1 := minDrawImageXY(t, d1) + + params2 := params + params2.CameraXWorldFp += 1 * SCALE + params2.CameraYWorldFp += 1 * SCALE + + w.ForceFullRedrawNext() + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params2)) + + minX2, minY2 := minDrawImageXY(t, d2) + + // With viewport anchoring, tiling origin is fixed (no camera dependency). + require.Equal(t, minX1, minX2) + require.Equal(t, minY1, minY2) +} + +func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) { + t.Helper() + + cmds := d.CommandsByName("DrawImage") + require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling") + + minX := int(cmds[0].Args[0]) + minY := int(cmds[0].Args[1]) + + for _, c := range cmds[1:] { + x := int(c.Args[0]) + y := int(c.Args[1]) + if x < minX { + minX = x + } + if y < minY { + minY = y + } + } + return minX, minY +} + +type bgOffsetTheme struct { + img image.Image + scaleMode BackgroundScaleMode +} + +func (t bgOffsetTheme) ID() string { return "bgscale" } +func (t bgOffsetTheme) Name() string { return "bgscale" } + +func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img } + +func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } +func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } +func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } + +func (t bgOffsetTheme) PointStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} +} +func (t bgOffsetTheme) LineStyle() Style { + return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} +func (t bgOffsetTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +// TestRender_BackgroundScaleNone_UsesOffsetDrawImage verifies render Background Scale None Uses Offset Draw Image. +func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone}) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + require.NotEmpty(t, d.CommandsByName("DrawImage")) + require.Empty(t, d.CommandsByName("DrawImageScaled")) +} + +// TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled verifies render Background Scale Fit Uses Draw Offset Image Scaled. +func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit}) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) +} + +type bgScaleTheme struct { + img image.Image + scaleMode BackgroundScaleMode +} + +func (t bgScaleTheme) ID() string { return "bgscale" } +func (t bgScaleTheme) Name() string { return "bgscale" } + +func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t bgScaleTheme) BackgroundImage() image.Image { return t.img } + +func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } +func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } +func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } + +func (t bgScaleTheme) PointStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} +} +func (t bgScaleTheme) LineStyle() Style { + return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} +func (t bgScaleTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +// TestRender_BackgroundScaleNone_UsesDrawImage verifies render Background Scale None Uses Draw Image. +func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone}) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + require.NotEmpty(t, d.CommandsByName("DrawImage")) + require.Empty(t, d.CommandsByName("DrawImageScaled")) +} + +// TestRender_BackgroundScaleFit_UsesDrawImageScaled verifies render Background Scale Fit Uses Draw Image Scaled. +func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit}) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) +} + +type bgTheme struct { + img image.Image +} + +func (t bgTheme) ID() string { return "bg" } +func (t bgTheme) Name() string { return "bg" } + +func (t bgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t bgTheme) BackgroundImage() image.Image { return t.img } + +func (t bgTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } +func (t bgTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } +func (t bgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } + +func (t bgTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } +func (t bgTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } +func (t bgTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t bgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t bgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } +func (t bgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +// TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw verifies render Background Image Draws Before Primitives Full Redraw. +func TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // 4x4 opaque image + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgTheme{img: img}) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + iClear := indexOfFirstName(cmds, "ClearAllTo") + iBg := indexOfFirstName(cmds, "DrawImage") + iPrim := indexOfFirstName(cmds, "AddPoint") + + require.NotEqual(t, -1, iClear) + require.NotEqual(t, -1, iBg) + require.NotEqual(t, -1, iPrim) + require.Less(t, iClear, iBg) + require.Less(t, iBg, iPrim) +} + +// TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift verifies render Background Image Redrawn In Dirty Rects On Incremental Shift. +func TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + w.SetTheme(bgTheme{img: img}) + + // Ensure state: first full render commits. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: &IncrementalPolicy{ + AllowShiftOnly: false, + MaxCatchUpAreaPx: 0, + RenderBudgetMs: 0, + CoalesceUpdates: false, + }, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + // Move camera by 1px right in world (zoom=1 => 1px == 1 unit). + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params2)) + + // In incremental shift path we must see ClearRectTo and DrawImage. + require.NotEmpty(t, d2.CommandsByName("CopyShift")) + require.NotEmpty(t, d2.CommandsByName("ClearRectTo")) + require.NotEmpty(t, d2.CommandsByName("DrawImage")) +} + +// TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips verifies draw Circles From Plan Duplicates Across Tiles And Clips. +func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { + t.Parallel() + + // World is 10x10 world units => 10000x10000 fixed. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Circle near origin so that in expanded canvas (bigger than world) + // it will appear in multiple torus tiles. + id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000 + require.NoError(t, err) + + w.indexObject(w.objects[id]) + + // Same geometry as points-only test: + // viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) + + // Expect 4 circle copies, one per tile that covers the expanded canvas. + wantNames := []string{ + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + // At zoom=1, 1 world unit -> 1 px, so: + // circle center at (1,1) => base copy at (3,3) like point test + // radius 1 => 1 px + // + // The rest are shifted by +10px in X and/or Y due to torus tiling. + { + clip := requireDrawerCommandAt(t, d, 1) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 2, 10, 10) + + c := requireDrawerCommandAt(t, d, 2) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 3, 3, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 6) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 12, 10, 2) + + c := requireDrawerCommandAt(t, d, 7) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 3, 13, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 11) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 2, 2, 10) + + c := requireDrawerCommandAt(t, d, 12) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 13, 3, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 16) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 12, 2, 2) + + c := requireDrawerCommandAt(t, d, 17) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 13, 13, 1) + } +} + +// TestDrawCirclesFromPlan_SkipsTilesWithoutCircles verifies draw Circles From Plan Skips Tiles Without Circles. +func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Add only a point, no circles. + id, err := w.AddPoint(5, 5) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) + + // No circles => no commands. + require.Empty(t, d.Commands()) +} + +// TestDrawCirclesFromPlan_ProjectsRadiusWithZoom verifies draw Circles From Plan Projects Radius With Zoom. +func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) { + t.Parallel() + + w := NewWorld(100, 100) + w.resetGrid(10 * SCALE) + + // radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1. + id, err := w.AddCircle(50, 50, 2) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 50 * SCALE, + CameraYWorldFp: 50 * SCALE, + CameraZoom: 2.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) + + // There should be at least one AddCircle. + cmds := d.CommandsByName("AddCircle") + require.NotEmpty(t, cmds) + + // All circles in this plan should have radius 4px (2 units * 2x zoom). + for _, c := range cmds { + require.Len(t, c.Args, 3) + require.Equal(t, 4.0, c.Args[2]) + } +} + +// TestCircles_NoWrap_DoesNotDuplicateAcrossEdges verifies circles No Wrap Does Not Duplicate Across Edges. +func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) + w.resetGrid(2 * SCALE) + + _, err := w.AddCircle(9, 9, 2) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + DisableWrapScroll: true, + Layers: []RenderLayer{RenderLayerCircles}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.CommandsByName("AddCircle") + require.Len(t, cmds, 1) + + // Center must be at (9,9) only, no (-1,*) or (*,-1). + require.Equal(t, []float64{9, 9, 2}, cmds[0].Args) +} + +// TestRender_CircleTransparentFill_UsesStrokeNotFill verifies render Circle Transparent Fill Uses Stroke Not Fill. +func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + sw := 4.0 + circleStyle := w.AddStyleCircle(StyleOverride{ + FillColor: color.RGBA{A: 0}, // explicitly transparent + StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + StrokeWidthPx: &sw, + }) + + _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + + iAdd := indexOfFirstName(cmds, "AddCircle") + require.NotEqual(t, -1, iAdd) + + // After AddCircle we must see Stroke (not Fill). + iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds))) + iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds))) + + require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()") + require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible") +} + +// TestRender_CircleFillAndStroke_DrawsFillThenStroke verifies render Circle Fill And Stroke Draws Fill Then Stroke. +func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + sw := 2.0 + styleID := w.AddStyleCircle(StyleOverride{ + FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255}, + StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + StrokeWidthPx: &sw, + }) + + _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + iAdd := indexOfFirstName(cmds, "AddCircle") + require.NotEqual(t, -1, iAdd) + + iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds))) + iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds))) + + require.NotEqual(t, -1, iFill, "expected Fill() for visible fill") + require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke") + require.Less(t, iFill, iStroke, "Stroke must be last when both are visible") +} + +// TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies circles Wrap Copies Appear Inside Viewport When Viewport Equals World. +func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { + t.Parallel() + + // World 10x10 units => 10px at zoom=1 when viewport==world. + w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) + w.resetGrid(2 * SCALE) + + type tc struct { + name string + x, y float64 + r float64 + wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0 + } + + // Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + tests := []tc{ + { + name: "bottom boundary wraps to top", + x: 5, y: 9, r: 2, + // Centers: original at y=9, copy at y=-1. + wantCenters: [][2]float64{{5, 9}, {5, -1}}, + }, + { + name: "right boundary wraps to left", + x: 9, y: 5, r: 2, + wantCenters: [][2]float64{{9, 5}, {-1, 5}}, + }, + { + name: "corner wraps to three extra copies", + x: 9, y: 9, r: 2, + wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}}, + }, + { + name: "no wrap inside", + x: 5, y: 5, r: 2, + wantCenters: [][2]float64{{5, 5}}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w2 := NewWorld(10, 10) + w2.resetGrid(2 * SCALE) + + _, err := w2.AddCircle(tt.x, tt.y, tt.r) + require.NoError(t, err) + + for _, obj := range w2.objects { + w2.indexObject(obj) + } + + plan, err := w2.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp) + + cmds := d.CommandsByName("AddCircle") + require.Len(t, cmds, len(tt.wantCenters)) + + // Collect centers (ignore radius for this test). + got := make([][2]float64, 0, len(cmds)) + for _, c := range cmds { + require.Len(t, c.Args, 3) + got = append(got, [2]float64{c.Args[0], c.Args[1]}) + } + + // Order is deterministic with our shift generation and tile iteration for margin=0: single tile. + require.ElementsMatch(t, tt.wantCenters, got) + }) + } +} + +// TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop verifies render Shift Only Over Budget Defers Dirty And Catches Up On Stop. +func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, // 1ms budget + }, + }, + } + + // First render (full) initializes state. + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + require.True(t, w.renderState.initialized) + + // Pretend previous render was very slow => over budget for the next frame. + w.renderState.lastRenderDurationNs = 10_000_000 // 10ms + + // Pan right by 1 unit => incremental shift candidate. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, params2)) + + // Shift-only should call CopyShift but not redraw dirty rects. + require.NotEmpty(t, d1.CommandsByName("CopyShift")) + require.Empty(t, d1.CommandsByName("ClipRect")) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty. + w.renderState.lastRenderDurationNs = 0 // under budget + + params3 := params2 // same camera + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params3)) + + require.NotEmpty(t, d2.CommandsByName("ClipRect")) + require.NotEmpty(t, d2.CommandsByName("AddPoint")) + require.Empty(t, w.renderState.pendingDirty) +} + +// TestRender_CatchUpWhilePanning_WhenBackUnderBudget verifies render Catch Up While Panning When Back Under Budget. +func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + policy := &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, + } + + base := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: policy, + }, + } + + // Initial full render. + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) + + // Frame 1: over budget => shift-only, pendingDirty accumulates. + w.renderState.lastRenderDurationNs = 10_000_000 // 10ms + + p1 := base + p1.CameraXWorldFp += 1 * SCALE + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, p1)) + + require.NotEmpty(t, d1.CommandsByName("CopyShift")) + require.Empty(t, d1.CommandsByName("ClipRect")) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty). + w.renderState.lastRenderDurationNs = 0 + + p2 := p1 + p2.CameraXWorldFp += 1 * SCALE + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, p2)) + + require.NotEmpty(t, d2.CommandsByName("CopyShift")) + require.NotEmpty(t, d2.CommandsByName("ClipRect")) + require.NotEmpty(t, d2.CommandsByName("AddPoint")) + require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw") +} + +// TestRender_CatchUpLimit_ReducesPendingDirtyGradually verifies render Catch Up Limit Reduces Pending Dirty Gradually. +func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + policy := &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, + MaxCatchUpAreaPx: 20, // very small budget + } + + base := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 2, + MarginXPx: 4, + MarginYPx: 4, // canvasH = 2 + 8 = 10 + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: policy, + }, + } + + // Full init + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) + + // Over budget => shift-only twice to accumulate pending dirty. + w.renderState.lastRenderDurationNs = 10_000_000 + + p1 := base + p1.CameraXWorldFp += 1 * SCALE + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1)) + require.NotEmpty(t, w.renderState.pendingDirty) + + w.renderState.lastRenderDurationNs = 10_000_000 + p2 := p1 + p2.CameraXWorldFp += 1 * SCALE + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Under budget now, but limit catch-up. + w.renderState.lastRenderDurationNs = 0 + + before := len(w.renderState.pendingDirty) + require.Greater(t, before, 0) + + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) + after := len(w.renderState.pendingDirty) + + // With a tiny MaxCatchUpAreaPx we should not clear everything in one go. + require.Greater(t, after, 0) + require.Less(t, after, before) +} + +// TestTakeCatchUpRects_RespectsAreaLimit verifies take Catch Up Rects Respects Area Limit. +func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) { + t.Parallel() + + pending := []RectPx{ + {X: 0, Y: 0, W: 10, H: 1}, // area 10 + {X: 0, Y: 1, W: 10, H: 2}, // area 20 + {X: 0, Y: 3, W: 10, H: 3}, // area 30 + } + + // Limit 25 => should take first (10) + second (20) would exceed => take only first. + sel, rem := takeCatchUpRects(pending, 25) + require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel) + require.Equal(t, []RectPx{ + {X: 0, Y: 1, W: 10, H: 2}, + {X: 0, Y: 3, W: 10, H: 3}, + }, rem) + + // Limit 30 => can take first(10) + second(20) exactly. + sel, rem = takeCatchUpRects(pending, 30) + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 10, H: 1}, + {X: 0, Y: 1, W: 10, H: 2}, + }, sel) + require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem) + + // No limit => take all. + sel, rem = takeCatchUpRects(pending, 0) + require.Len(t, sel, 3) + require.Empty(t, rem) +} + +// TestPlanIncrementalPan_NoOp verifies plan Incremental Pan No Op. +func TestPlanIncrementalPan_NoOp(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0) + require.NoError(t, err) + require.Equal(t, IncrementalNoOp, plan.Mode) + require.Empty(t, plan.Dirty) +} + +// TestPlanIncrementalPan_FullRedrawOnInvalidCanvas verifies plan Incremental Pan Full Redraw On Invalid Canvas. +func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) { + t.Parallel() + + _, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0) + require.ErrorIs(t, err, errInvalidCanvasSize) +} + +// TestPlanIncrementalPan_FullRedrawOnTooLargeShift verifies plan Incremental Pan Full Redraw On Too Large Shift. +func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) + + plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +// TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero verifies plan Incremental Pan Full Redraw When Margin Is Zero And Delta Non Zero. +func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) + + plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX verifies plan Incremental Pan Full Redraw When Exceeds Threshold X. +func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) { + t.Parallel() + + // marginX=20 => threshold=10, dx=11 => full redraw + plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY verifies plan Incremental Pan Full Redraw When Exceeds Threshold Y. +func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) { + t.Parallel() + + // marginY=20 => threshold=10, dy=-11 => full redraw + plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +// TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive verifies plan Incremental Pan Shift Left Strip When Dx Positive. +func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) { + t.Parallel() + + // marginX=40 => threshold=20, dx=5 => shift ok + plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + require.Equal(t, 5, plan.DxPx) + require.Equal(t, 0, plan.DyPx) + + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 6, H: 100}, + }, plan.Dirty) +} + +// TestPlanIncrementalPan_Shift_RightStripWhenDxNegative verifies plan Incremental Pan Shift Right Strip When Dx Negative. +func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 200 - 8, Y: 0, W: 8, H: 100}, + }, plan.Dirty) +} + +// TestPlanIncrementalPan_Shift_TopStripWhenDyPositive verifies plan Incremental Pan Shift Top Strip When Dy Positive. +func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 200, H: 10}, + }, plan.Dirty) +} + +// TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative verifies plan Incremental Pan Shift Bottom Strip When Dy Negative. +func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 0, Y: 100 - 10, W: 200, H: 10}, + }, plan.Dirty) +} + +// TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects verifies plan Incremental Pan Shift Diagonal Returns Two Dirty Rects. +func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + // Overlap is allowed; we just require both strips exist. + require.Len(t, plan.Dirty, 2) + require.ElementsMatch(t, []RectPx{ + {X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip + {X: 0, Y: 0, W: 200, H: 9}, // top strip + }, plan.Dirty) +} + +// TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel verifies plan Incremental Pan Overdraws Dirty Strips By One Pixel. +func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + // Right strip width should be abs(dx)+1 = 8. + require.Equal(t, []RectPx{ + {X: 200 - 8, Y: 0, W: 8, H: 100}, + }, plan.Dirty) +} + +// TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips verifies render Pan Small Uses Copy Shift And Renders Only Dirty Strips. +func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + _, err = w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, // threshold=2 + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + // First render initializes state (full redraw). + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + + // Pan right by 1 unit => dx=-1 => incremental shift expected. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params2) + require.NoError(t, err) + + // Must contain CopyShift for incremental path. + require.NotEmpty(t, d.CommandsByName("CopyShift")) + + // All clip rects should be "small": width <= 1 for dx=-1 strip. + clipCmds := d.CommandsByName("ClipRect") + require.NotEmpty(t, clipCmds) + for _, c := range clipCmds { + wPx := int(c.Args[2]) + hPx := int(c.Args[3]) + require.LessOrEqual(t, wPx, 2) + require.LessOrEqual(t, hPx, params2.CanvasHeightPx()) + } + + require.NotEmpty(t, d.CommandsByName("AddPoint")) + require.NotEmpty(t, d.CommandsByName("AddCircle")) + require.NotEmpty(t, d.CommandsByName("AddLine")) +} + +// TestRender_PanTooLarge_FallsBackToFullRedraw verifies render Pan Too Large Falls Back To Full Redraw. +func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + + // Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected. + params2 := params + params2.CameraXWorldFp += 3 * SCALE + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params2) + require.NoError(t, err) + + // Full redraw should NOT call CopyShift. + require.Empty(t, d.CommandsByName("CopyShift")) + + // Full redraw should clear the entire canvas. + require.NotEmpty(t, d.CommandsByName("ClearAllTo")) + + // And should draw something (at least the point). + // Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well. + require.NotEmpty(t, d.CommandsByName("AddPoint")) +} + +// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive verifies world Delta Fixed To Canvas Px Remainder Accumulates Positive. +func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) { + t.Parallel() + + // zoom=1: px = (deltaWorldFp * 1000) / 1e6 + // For deltaWorldFp=1, each step contributes 0 px with remainder, + // and after 1000 steps it must become 1 px total. + zoomFp := SCALE + var rem int64 + sum := 0 + + for i := 0; i < 1000; i++ { + sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem) + } + + require.Equal(t, 1, sum) +} + +// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative verifies world Delta Fixed To Canvas Px Remainder Accumulates Negative. +func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) { + t.Parallel() + + zoomFp := SCALE + var rem int64 + sum := 0 + + for i := 0; i < 1000; i++ { + sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem) + } + + require.Equal(t, -1, sum) +} + +// TestComputePanShiftPx_FirstCallRequiresFullRedraw verifies compute Pan Shift Px First Call Requires Full Redraw. +func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + _, _, err := w.ComputePanShiftPx(params) + require.ErrorIs(t, err, errIncrementalStateNotReady) +} + +// TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw verifies compute Pan Shift Px Zoom Or Viewport Change Forces Full Redraw. +func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + base := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(base)) + + changed := base + changed.CameraZoom = 2.0 + + _, _, err := w.ComputePanShiftPx(changed) + require.ErrorIs(t, err, errIncrementalZoomMismatch) + + changed2 := base + changed2.ViewportWidthPx = 101 + + _, _, err = w.ComputePanShiftPx(changed2) + require.ErrorIs(t, err, errIncrementalZoomMismatch) +} + +// TestComputePanShiftPx_PanRightShiftsImageLeft verifies compute Pan Shift Px Pan Right Shifts Image Left. +func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Move camera right by 1 world unit => world rect minX increases by 1 unit, + // so content moves left by 1px at zoom=1 => image shift should be -1. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + dx, dy, err := w.ComputePanShiftPx(params2) + require.NoError(t, err) + require.Equal(t, -1, dx) + require.Equal(t, 0, dy) +} + +// TestComputePanShiftPx_PanUpShiftsImageDown verifies compute Pan Shift Px Pan Up Shifts Image Down. +func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Move camera up by 1 world unit => world rect minY decreases by 1 unit, + // so content moves down by 1px => image shift should be +1 in dy. + params2 := params + params2.CameraYWorldFp -= 1 * SCALE + + dx, dy, err := w.ComputePanShiftPx(params2) + require.NoError(t, err) + require.Equal(t, 0, dx) + require.Equal(t, 1, dy) +} + +// TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel verifies compute Pan Shift Px Sub Pixel Pan Accumulates To One Pixel. +func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Pan camera right by 0.001 world units (1 fixed-point) 1000 times. + // At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1. + totalDx := 0 + p := params + for i := 0; i < 1000; i++ { + p.CameraXWorldFp += 1 + dx, dy, err := w.ComputePanShiftPx(p) + require.NoError(t, err) + require.Equal(t, 0, dy) + totalDx += dx + } + + require.Equal(t, -1, totalDx) +} + +// TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits verifies torus Shortest Line Segments Tie Case Is Deterministic And Splits. +func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) { + t.Parallel() + + // World 10 units => 10000 fixed. + worldW := 10 * SCALE + worldH := 10 * SCALE + + // Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000). + // Deterministic rule chooses negative delta representation (wrap is applied). + l := Line{ + X1: 1 * SCALE, Y1: 5 * SCALE, + X2: 6 * SCALE, Y2: 5 * SCALE, + } + + segs := torusShortestLineSegments(l, worldW, worldH) + + // Expect two horizontal segments: + // [6000..10000] and [0..1000] at y=5000. + require.Len(t, segs, 2) + + // Direction is deterministic and follows the chosen negative-delta representation. + require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0]) + require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1]) +} + +// TestLines_NoWrap_TieCaseDoesNotWrap verifies lines No Wrap Tie Case Does Not Wrap. +func TestLines_NoWrap_TieCaseDoesNotWrap(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Tie-case along X: 1 -> 6 in world of 10. + _, err := w.AddLine(1, 5, 6, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + DisableWrapScroll: true, + Layers: []RenderLayer{RenderLayerLines}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + lines := d.CommandsByName("AddLine") + require.Len(t, lines, 1) + + // At zoom=1 and margin=0, world==canvas, so pixels equal world units. + require.Equal(t, []float64{1, 5, 6, 5}, lines[0].Args) +} + +// TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips verifies draw Points From Plan Duplicates Across Tiles And Clips. +func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { + t.Parallel() + + // World is 10x10 world units => 10000x10000 fixed. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Place a point near the origin so that expanded canvas (larger than world) + // will require torus repetition and the point will appear in multiple tiles. + id, err := w.AddPoint(1.0, 1.0) // (1000,1000) + require.NoError(t, err) + + // Index only this object. + w.indexObject(w.objects[id]) + + // Choose viewport such that viewport==world in pixels at zoom=1: + // - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px. + // - world width=10 units => 10 px. + // Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan, true) + + // We expect 4 point copies: + // (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1) + // due to expanded rect spanning beyond world on both axes. + wantNames := []string{ + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + pointRadiusPx := DefaultRenderStyle().PointRadiusPx + + // Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3). + { + clip := requireDrawerCommandAt(t, d, 1) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 2, 10, 10) + + pt := requireDrawerCommandAt(t, d, 2) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 3, 3, pointRadiusPx) + } + + // Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13). + { + clip := requireDrawerCommandAt(t, d, 6) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 12, 10, 2) + + pt := requireDrawerCommandAt(t, d, 7) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 3, 13, pointRadiusPx) + } + + // Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3). + { + clip := requireDrawerCommandAt(t, d, 11) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 2, 2, 10) + + pt := requireDrawerCommandAt(t, d, 12) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 13, 3, pointRadiusPx) + } + + // Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13). + { + clip := requireDrawerCommandAt(t, d, 16) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 12, 2, 2) + + pt := requireDrawerCommandAt(t, d, 17) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 13, 13, pointRadiusPx) + } +} + +// TestDrawPointsFromPlan_SkipsTilesWithoutPoints verifies draw Points From Plan Skips Tiles Without Points. +func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Add only a line, no points. + id, err := w.AddLine(2, 2, 8, 2) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan, true) + + // No points => no drawing commands at all. + require.Empty(t, d.Commands()) +} + +// TestWorldRender_PointsOnlyStageA verifies world Render Points Only Stage A. +func TestWorldRender_PointsOnlyStageA(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + + // Build index. In real UI it happens via IndexOnViewportChange. + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params) + require.NoError(t, err) + + // At least one point draw should happen. + require.Contains(t, d.CommandNames(), "AddPoint") +} + +// TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies points Wrap Copies Appear Inside Viewport When Viewport Equals World. +func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Style: func() *RenderStyle { + s := DefaultRenderStyle() + s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1 + return &s + }(), + }, + } + + type tc struct { + name string + x, y float64 + wantCenters [][2]float64 + } + + tests := []tc{ + { + name: "bottom boundary wraps to top", + x: 5, + y: 9, + wantCenters: [][2]float64{{5, 9}, {5, -1}}, + }, + { + name: "right boundary wraps to left", + x: 9, + y: 5, + wantCenters: [][2]float64{{9, 5}, {-1, 5}}, + }, + { + name: "corner wraps to three extra copies", + x: 9, + y: 9, + wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}}, + }, + { + name: "no wrap inside", + x: 5, + y: 5, + wantCenters: [][2]float64{{5, 5}}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(tt.x, tt.y) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + style := DefaultRenderStyle() + style.PointRadiusPx = 2.0 + + applyPointStyle(d, style) + drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, true) + + cmds := d.CommandsByName("AddPoint") + require.Len(t, cmds, len(tt.wantCenters)) + + got := make([][2]float64, 0, len(cmds)) + for _, c := range cmds { + require.Len(t, c.Args, 3) + got = append(got, [2]float64{c.Args[0], c.Args[1]}) + } + + require.ElementsMatch(t, tt.wantCenters, got) + }) + } +} + +// TestWorldRender_DrawsAllLayersInDefaultOrder verifies world Render Draws All Layers In Default Order. +func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(1, 1) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + _, err = w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params) + require.NoError(t, err) + + names := d.CommandNames() + require.Contains(t, names, "AddPoint") + require.Contains(t, names, "AddCircle") + require.Contains(t, names, "AddLine") +} + +// TestSmoke_DrawPointsAndCirclesFromSamePlan verifies smoke Draw Points And Circles From Same Plan. +func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(1, 1) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlan(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan, true) + drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) + + names := d.CommandNames() + require.Contains(t, names, "AddPoint") + require.Contains(t, names, "AddCircle") +} + +// TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile verifies render Applies Style Before Add Commands For First Item In Tile. +func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Create a derived circle style so we can observe a style application transition. + red := color.RGBA{R: 255, A: 255} + styleID := w.AddStyleCircle(StyleOverride{FillColor: red}) + + _, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + + iSetFill := indexOfFirstName(cmds, "SetFillColor") + iAddCircle := indexOfFirstName(cmds, "AddCircle") + require.NotEqual(t, -1, iSetFill) + require.NotEqual(t, -1, iAddCircle) + + require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle") +} + +// TestRender_DoesNotReapplySameStyleAcrossMultipleObjects verifies render Does Not Reapply Same Style Across Multiple Objects. +func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Two lines with the same default line style and same priority. + _, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100)) + require.NoError(t, err) + _, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + // We expect style application at least once. + setWidth := d.CommandsByName("SetLineWidth") + require.NotEmpty(t, setWidth) + + // The key batching assertion: style setters should not be called twice *between* two AddLine calls. + cmds := d.Commands() + line1 := indexOfFirstName(cmds, "AddLine") + require.NotEqual(t, -1, line1) + + line2 := indexOfNextName(cmds, "AddLine", line1+1) + require.NotEqual(t, -1, line2) + + // Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset, + // because StyleID is the same and the renderer caches lastStyleID. + for i := line1 + 1; i < line2; i++ { + switch cmds[i].Name { + case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor": + t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i) + } + } +} + +// TestRender_ReappliesStyleWhenStyleIDChanges verifies render Reapplies Style When Style ID Changes. +func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Two circles, different derived fill colors => different StyleIDs. + red := color.RGBA{R: 255, A: 255} + green := color.RGBA{G: 255, A: 255} + + styleRed := w.AddStyleCircle(StyleOverride{FillColor: red}) + styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green}) + + _, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100)) + require.NoError(t, err) + _, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + firstCircle := indexOfFirstName(cmds, "AddCircle") + secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1) + require.NotEqual(t, -1, firstCircle) + require.NotEqual(t, -1, secondCircle) + + // There must be at least one SetFillColor before each circle. + // And importantly, we expect a SetFillColor BETWEEN the two circles due to style change. + setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle) + require.NotEqual(t, -1, setBeforeFirst) + + setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle) + require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs") +} + +/* ---------- helper functions for fake command slices ---------- */ + +func indexOfFirstName(cmds []fakeDrawerCommand, name string) int { + for i, c := range cmds { + if c.Name == name { + return i + } + } + return -1 +} + +func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int { + for i := start; i < len(cmds); i++ { + if cmds[i].Name == name { + return i + } + } + return -1 +} + +func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int { + if before > len(cmds) { + before = len(cmds) + } + for i := before - 1; i >= 0; i-- { + if cmds[i].Name == name { + return i + } + } + return -1 +} + +func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int { + if start < 0 { + start = 0 + } + if end > len(cmds) { + end = len(cmds) + } + for i := start; i < end; i++ { + if cmds[i].Name == name { + return i + } + } + return -1 +} + +// rendererTestEnv groups the common mutable inputs used by renderer tests. +// The environment stores independent horizontal and vertical margins because +// the expanded canvas geometry is derived separately on each axis. +type rendererTestEnv struct { + world *World + drawer *fakePrimitiveDrawer + + // Viewport origin and size in canvas pixel coordinates. + viewportX int + viewportY int + viewportW int + viewportH int + + // Independent margins around the viewport in canvas pixels. + marginXPx int + marginYPx int + + // Final expanded canvas size in pixels. + // In the default setup: + // canvasW = viewportW + 2*marginXPx + // canvasH = viewportH + 2*marginYPx + canvasW int + canvasH int + + // Camera center in fixed-point world coordinates. + cameraX int + cameraY int + + // Camera zoom in fixed-point representation, if needed by renderer internals. + zoomFp int +} + +// newRendererTestEnv returns a baseline renderer test environment. +// The default margins are derived independently from viewport width and height. +func newRendererTestEnv() *rendererTestEnv { + viewportW := 100 + viewportH := 80 + + marginXPx := viewportW / 4 + marginYPx := viewportH / 4 + + return &rendererTestEnv{ + world: NewWorld(10, 10), + drawer: &fakePrimitiveDrawer{}, + viewportX: marginXPx, + viewportY: marginYPx, + viewportW: viewportW, + viewportH: viewportH, + marginXPx: marginXPx, + marginYPx: marginYPx, + canvasW: viewportW + 2*marginXPx, + canvasH: viewportH + 2*marginYPx, + cameraX: 5 * SCALE, + cameraY: 5 * SCALE, + zoomFp: SCALE, + } +} + +// setViewport resets viewport-dependent fields and recomputes margins +// using the default test formula: +// +// marginXPx = viewportW / 4 +// marginYPx = viewportH / 4 +func (env *rendererTestEnv) setViewport(viewportW, viewportH int) { + env.viewportW = viewportW + env.viewportH = viewportH + + env.marginXPx = viewportW / 4 + env.marginYPx = viewportH / 4 + + env.viewportX = env.marginXPx + env.viewportY = env.marginYPx + + env.canvasW = env.viewportW + 2*env.marginXPx + env.canvasH = env.viewportH + 2*env.marginYPx +} + +// setViewportAndMargins overrides viewport and margins explicitly. +// This is useful for edge cases where the expanded canvas geometry +// must be controlled exactly. +func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) { + env.viewportW = viewportW + env.viewportH = viewportH + + env.marginXPx = marginXPx + env.marginYPx = marginYPx + + env.viewportX = env.marginXPx + env.viewportY = env.marginYPx + + env.canvasW = env.viewportW + 2*env.marginXPx + env.canvasH = env.viewportH + 2*env.marginYPx +} + +// viewportRect returns the viewport rectangle in canvas pixel coordinates. +func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) { + return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH) +} + +// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates. +func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) { + return 0, 0, float64(env.canvasW), float64(env.canvasH) +} + +// worldMustAddPoint adds a point to the test world and fails the test on error. +func worldMustAddPoint(t *testing.T, w *World, x, y float64) { + t.Helper() + + _, err := w.AddPoint(x, y) + require.NoError(t, err) +} + +// worldMustAddCircle adds a circle to the test world and fails the test on error. +func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) { + t.Helper() + + _, err := w.AddCircle(x, y, r) + require.NoError(t, err) +} + +// worldMustAddLine adds a line to the test world and fails the test on error. +func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) { + t.Helper() + + _, err := w.AddLine(x1, y1, x2, y2) + require.NoError(t, err) +} + +// requireNoDrawerCommands asserts that the renderer produced no drawing commands. +func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) { + t.Helper() + + require.Empty(t, d.Commands()) +} + +// requireStrokeCommandAt returns a command and asserts that it is Stroke. +func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "Stroke") + return cmd +} + +// requireFillCommandAt returns a command and asserts that it is Fill. +func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "Fill") + return cmd +} + +// requireAddPointCommandAt returns a command and asserts that it is AddPoint. +func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddPoint") + return cmd +} + +// requireAddLineCommandAt returns a command and asserts that it is AddLine. +func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddLine") + return cmd +} + +// requireAddCircleCommandAt returns a command and asserts that it is AddCircle. +func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddCircle") + return cmd +} + +// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect. +func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) { + t.Helper() + + requireCommandClipRects(t, cmd, fakeClipRect{ + X: x, + Y: y, + W: w, + H: h, + }) +} + +// rendererTestCase is a generic table-driven renderer test scaffold. +// Replace invoke with the real renderer call once the renderer exists. +type rendererTestCase struct { + name string + + // setup prepares the world and optional environment overrides. + setup func(t *testing.T, env *rendererTestEnv) + + // invoke calls the renderer under test. + invoke func(t *testing.T, env *rendererTestEnv) + + // verify checks the produced fake drawer log. + verify func(t *testing.T, env *rendererTestEnv) +} + +func runRendererTestCases(t *testing.T, cases []rendererTestCase) { + t.Helper() + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + env := newRendererTestEnv() + + if tc.setup != nil { + tc.setup(t, env) + } + + require.NotNil(t, tc.invoke, "renderer test case must define invoke") + require.NotNil(t, tc.verify, "renderer test case must define verify") + + tc.invoke(t, env) + tc.verify(t, env) + }) + } +} + +// TestRenderer_Template_PointCases is a scaffold for future point renderer tests. +func TestRenderer_Template_PointCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "point fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddPoint(t, env.world, 5, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point visible only in horizontal margin copy", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddPoint(t, env.world, 0.1, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point visible only in vertical margin copy", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddPoint(t, env.world, 5, 0.1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point duplicated across torus corner with independent margins", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewportAndMargins(120, 60, 30, 10) + worldMustAddPoint(t, env.world, 0.1, 0.1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} + +// TestRenderer_Template_LineCases is a scaffold for future line renderer tests. +func TestRenderer_Template_LineCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "line fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddLine(t, env.world, 2, 2, 8, 2) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line wrap copy across x edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddLine(t, env.world, 9, 5, 1, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line wrap copy across y edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddLine(t, env.world, 5, 9, 5, 1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line tie case uses deterministic wrapped representation", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddLine(t, env.world, 1, 5, 6, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} + +// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests. +func TestRenderer_Template_CircleCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "circle fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddCircle(t, env.world, 5, 5, 1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across horizontal edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddCircle(t, env.world, 0.2, 5, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across vertical edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddCircle(t, env.world, 5, 0.2, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across corner with asymmetric margins", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewportAndMargins(120, 60, 30, 10) + worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} + +type pointRadiusTheme struct { + id string + radius float64 +} + +func (t pointRadiusTheme) ID() string { return t.id } +func (t pointRadiusTheme) Name() string { return t.id } + +func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } +func (t pointRadiusTheme) BackgroundImage() image.Image { return nil } + +func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } +func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } +func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } + +func (t pointRadiusTheme) PointStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius} +} +func (t pointRadiusTheme) LineStyle() Style { + return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} +func (t pointRadiusTheme) CircleStyle() Style { + return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} +} + +func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} +func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { + return StyleOverride{}, false +} + +// TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles verifies render Theme Change Applies Without Reindex Uses Latest Object Styles. +func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + // Build index once. + w.IndexOnViewportChange(100, 100, 1.0) + + // Theme A: point radius 2 + w.SetTheme(pointRadiusTheme{id: "A", radius: 2}) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + + // Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state). + // Render once. + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, params)) + + p1 := d1.CommandsByName("AddPoint") + require.NotEmpty(t, p1) + r1 := p1[0].Args[2] + require.Equal(t, 2.0, r1) + + // Theme B: point radius 7. Change theme, but DO NOT reindex. + w.SetTheme(pointRadiusTheme{id: "B", radius: 7}) + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params)) + + p2 := d2.CommandsByName("AddPoint") + require.NotEmpty(t, p2) + r2 := p2[0].Args[2] + require.Equal(t, 7.0, r2) +} diff --git a/client/world/renderer_test_helpers_test.go b/client/world/renderer_test_helpers_test.go deleted file mode 100644 index 67cea28..0000000 --- a/client/world/renderer_test_helpers_test.go +++ /dev/null @@ -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, - }) -} diff --git a/client/world/renderer_test_template_test.go b/client/world/renderer_test_template_test.go deleted file mode 100644 index f8f59f9..0000000 --- a/client/world/renderer_test_template_test.go +++ /dev/null @@ -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) - }, - }, - }) -} diff --git a/client/world/renderer_theme_hotswap_test.go b/client/world/renderer_theme_hotswap_test.go deleted file mode 100644 index 6b03cf6..0000000 --- a/client/world/renderer_theme_hotswap_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/style.go b/client/world/style.go index aeb2b14..ffdae97 100644 --- a/client/world/style.go +++ b/client/world/style.go @@ -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 +} diff --git a/client/world/style_cache_test.go b/client/world/style_cache_test.go deleted file mode 100644 index c1c1788..0000000 --- a/client/world/style_cache_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/style_override_fingerprint.go b/client/world/style_override_fingerprint.go deleted file mode 100644 index da3ce04..0000000 --- a/client/world/style_override_fingerprint.go +++ /dev/null @@ -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() -} diff --git a/client/world/style_override_merge.go b/client/world/style_override_merge.go deleted file mode 100644 index 440f2c5..0000000 --- a/client/world/style_override_merge.go +++ /dev/null @@ -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 -} diff --git a/client/world/style_test.go b/client/world/style_test.go index fe831ca..a8bd7a6 100644 --- a/client/world/style_test.go +++ b/client/world/style_test.go @@ -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 +} diff --git a/client/world/theme.go b/client/world/theme.go deleted file mode 100644 index c892b4e..0000000 --- a/client/world/theme.go +++ /dev/null @@ -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 -} diff --git a/client/world/theme_classes_test.go b/client/world/theme_classes_test.go deleted file mode 100644 index 5ef67ac..0000000 --- a/client/world/theme_classes_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/theme_default.go b/client/world/theme_default.go deleted file mode 100644 index 556d08b..0000000 --- a/client/world/theme_default.go +++ /dev/null @@ -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 -} diff --git a/client/world/theme_override_test.go b/client/world/theme_override_test.go deleted file mode 100644 index cde6bee..0000000 --- a/client/world/theme_override_test.go +++ /dev/null @@ -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 -} diff --git a/client/world/theme_test.go b/client/world/theme_test.go deleted file mode 100644 index c9b5722..0000000 --- a/client/world/theme_test.go +++ /dev/null @@ -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")) -} diff --git a/client/world/torus_line_segments_into.go b/client/world/torus_line_segments_into.go deleted file mode 100644 index c65c7c5..0000000 --- a/client/world/torus_line_segments_into.go +++ /dev/null @@ -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 -} diff --git a/client/world/util.go b/client/world/util.go index 1906297..15ec10b 100644 --- a/client/world/util.go +++ b/client/world/util.go @@ -3,7 +3,11 @@ package world import ( "errors" "fmt" + "hash" + "hash/fnv" + "image/color" "math" + "math/bits" ) var ( @@ -248,3 +252,1181 @@ func mustCameraZoomToWorldFixed(cameraZoom float64) int { } return zoomFp } + +// 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 +} + +// clampCameraAxis clamps one camera axis for bounded-world rendering. +// +// If the visible span is larger than the world on that axis, the camera is +// forced to the world center to keep the result deterministic. +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 +} + +// 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 +} + +// 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) +} + +// u128 is an unsigned 128-bit integer for safe squared comparisons. +type u128 struct{ hi, lo uint64 } + +// u128FromMul64 returns the full 128-bit product of two uint64 values. +func u128FromMul64(a, b uint64) u128 { + hi, lo := bits.Mul64(a, b) + return u128{hi: hi, lo: lo} +} + +// u128Add returns the 128-bit sum a+b. +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} +} + +// u128Cmp compares two unsigned 128-bit values. +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 +} + +// abs64 returns the absolute value of x. +func abs64(x int64) int64 { + if x < 0 { + return -x + } + return x +} + +// sqU128Int64 returns x*x as an unsigned 128-bit value. +func sqU128Int64(x int64) u128 { + u := uint64(abs64(x)) + return u128FromMul64(u, u) +} + +// distSqU128 returns dx*dx + dy*dy as an unsigned 128-bit value. +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 +} + +// effectiveHitSlopPx resolves the per-primitive hit slop, falling back to def +// when the primitive does not override it explicitly. +func effectiveHitSlopPx(hitSlopPx int, def int) int { + if hitSlopPx > 0 { + return hitSlopPx + } + return def +} + +// alphaNonZero reports whether c is non-nil and has a non-zero alpha channel. +func alphaNonZero(c color.Color) bool { + if c == nil { + return false + } + _, _, _, a := c.RGBA() + return a != 0 +} + +// hitPoint performs point hit testing in world-fixed coordinates. +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 +} + +// hitCircle performs circle hit testing in world-fixed coordinates, including +// fill-vs-stroke semantics and minimum point-like radius handling. +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 +} + +// hitLine performs line hit testing against the torus-shortest segment set used +// by the renderer. +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} +} + +// 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 +} + +// mergeOverrides applies userOv on top of classOv, preserving the "nil means +// not specified" semantics used by StyleOverride fields. +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 +} + +// 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[:]) +} + +// hashBool writes a boolean value to the fingerprint stream. +func hashBool(h hash.Hash64, v bool) { + if v { + hashU64(h, 1) + } else { + hashU64(h, 0) + } +} + +// hashColor writes a color value to the fingerprint stream. +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() +} + +// 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() + } +} + +// pointWrapShifts returns the torus-copy offsets required for a point marker +// whose visible disc may cross world edges. +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 +} + +// pointCopyIntersectsTile reports whether a particular wrapped point copy can +// contribute pixels inside the given world tile. +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 +} + +// 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() + } +} + +// wrapShift stores one torus-copy offset in world-fixed coordinates. +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 +} diff --git a/client/world/util_test.go b/client/world/util_test.go index da27a53..f09a07d 100644 --- a/client/world/util_test.go +++ b/client/world/util_test.go @@ -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) +} diff --git a/client/world/world.go b/client/world/world.go index 644f817..c51be40 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -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 } +} diff --git a/client/world/world_id_allocator_test.go b/client/world/world_id_allocator_test.go deleted file mode 100644 index a2e7ef1..0000000 --- a/client/world/world_id_allocator_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/world_options_test.go b/client/world/world_options_test.go deleted file mode 100644 index 42f2a18..0000000 --- a/client/world/world_options_test.go +++ /dev/null @@ -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) -} diff --git a/client/world/world_test.go b/client/world/world_test.go index 28d1b77..e281a9f 100644 --- a/client/world/world_test.go +++ b/client/world/world_test.go @@ -2,6 +2,9 @@ package world import ( "errors" + "fmt" + "github.com/stretchr/testify/require" + "image/color" "testing" ) @@ -63,6 +66,7 @@ func assertOccupiedCells(t *testing.T, w *World, id PrimitiveID, want ...[2]int) } } +// TestNewWorld verifies new World. func TestNewWorld(t *testing.T) { t.Parallel() @@ -82,6 +86,7 @@ func TestNewWorld(t *testing.T) { } } +// TestNewWorldPanicsOnInvalidSize verifies new World Panics On Invalid Size. func TestNewWorldPanicsOnInvalidSize(t *testing.T) { t.Parallel() @@ -112,6 +117,7 @@ func TestNewWorldPanicsOnInvalidSize(t *testing.T) { } } +// TestCheckCoordinate verifies check Coordinate. func TestCheckCoordinate(t *testing.T) { t.Parallel() @@ -139,6 +145,7 @@ func TestCheckCoordinate(t *testing.T) { } } +// TestAddPoint verifies add Point. func TestAddPoint(t *testing.T) { t.Parallel() @@ -163,6 +170,7 @@ func TestAddPoint(t *testing.T) { } } +// TestAddPointRejectsOutOfBounds verifies add Point Rejects Out Of Bounds. func TestAddPointRejectsOutOfBounds(t *testing.T) { t.Parallel() @@ -189,6 +197,7 @@ func TestAddPointRejectsOutOfBounds(t *testing.T) { } } +// TestAddPointAllowsLastRoundedInsideValue verifies add Point Allows Last Rounded Inside Value. func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) { t.Parallel() @@ -205,6 +214,7 @@ func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) { } } +// TestAddCircle verifies add Circle. func TestAddCircle(t *testing.T) { t.Parallel() @@ -229,6 +239,7 @@ func TestAddCircle(t *testing.T) { } } +// TestAddCircleAllowsZeroRadius verifies add Circle Allows Zero Radius. func TestAddCircleAllowsZeroRadius(t *testing.T) { t.Parallel() @@ -245,6 +256,7 @@ func TestAddCircleAllowsZeroRadius(t *testing.T) { } } +// TestAddCircleRejectsInvalidInput verifies add Circle Rejects Invalid Input. func TestAddCircleRejectsInvalidInput(t *testing.T) { t.Parallel() @@ -273,6 +285,7 @@ func TestAddCircleRejectsInvalidInput(t *testing.T) { } } +// TestAddLine verifies add Line. func TestAddLine(t *testing.T) { t.Parallel() @@ -298,6 +311,7 @@ func TestAddLine(t *testing.T) { } } +// TestAddLineRejectsInvalidInput verifies add Line Rejects Invalid Input. func TestAddLineRejectsInvalidInput(t *testing.T) { t.Parallel() @@ -326,6 +340,7 @@ func TestAddLineRejectsInvalidInput(t *testing.T) { } } +// TestResetGrid verifies reset Grid. func TestResetGrid(t *testing.T) { t.Parallel() @@ -351,6 +366,7 @@ func TestResetGrid(t *testing.T) { } } +// TestWorldToCellXY verifies world To Cell XY. func TestWorldToCellXY(t *testing.T) { t.Parallel() @@ -370,6 +386,7 @@ func TestWorldToCellXY(t *testing.T) { } } +// TestIndexObjectPoint verifies index Object Point. func TestIndexObjectPoint(t *testing.T) { t.Parallel() @@ -384,6 +401,7 @@ func TestIndexObjectPoint(t *testing.T) { cellIsEmpty(t, w, 4, 4) } +// TestIndexObjectCircleWithoutWrap verifies index Object Circle Without Wrap. func TestIndexObjectCircleWithoutWrap(t *testing.T) { t.Parallel() @@ -399,6 +417,7 @@ func TestIndexObjectCircleWithoutWrap(t *testing.T) { ) } +// TestIndexObjectCircleWrapsAcrossCorner verifies index Object Circle Wraps Across Corner. func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) { t.Parallel() @@ -416,6 +435,7 @@ func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) { ) } +// TestIndexObjectCircleCoversWholeWorld verifies index Object Circle Covers Whole World. func TestIndexObjectCircleCoversWholeWorld(t *testing.T) { t.Parallel() @@ -435,6 +455,7 @@ func TestIndexObjectCircleCoversWholeWorld(t *testing.T) { assertOccupiedCells(t, w, id, want...) } +// TestIndexObjectVerticalLineExpandsDegenerateX verifies index Object Vertical Line Expands Degenerate X. func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) { t.Parallel() @@ -451,6 +472,7 @@ func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) { ) } +// TestIndexObjectHorizontalLineExpandsDegenerateY verifies index Object Horizontal Line Expands Degenerate Y. func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) { t.Parallel() @@ -467,6 +489,7 @@ func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) { ) } +// TestIndexObjectLineWrapsAcrossX verifies index Object Line Wraps Across X. func TestIndexObjectLineWrapsAcrossX(t *testing.T) { t.Parallel() @@ -482,6 +505,7 @@ func TestIndexObjectLineWrapsAcrossX(t *testing.T) { ) } +// TestIndexObjectLineWrapsAcrossY verifies index Object Line Wraps Across Y. func TestIndexObjectLineWrapsAcrossY(t *testing.T) { t.Parallel() @@ -497,6 +521,7 @@ func TestIndexObjectLineWrapsAcrossY(t *testing.T) { ) } +// TestIndexObjectLineTieCaseUsesDeterministicWrap verifies index Object Line Tie Case Uses Deterministic Wrap. func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) { t.Parallel() @@ -521,6 +546,7 @@ func (u unknown) ID() PrimitiveID { return u.id } +// TestIndexBBoxPanicsOnUnknownItemType verifies index B Box Panics On Unknown Item Type. func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) { t.Parallel() @@ -534,3 +560,1334 @@ func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) { w.indexObject(unknown{id: PrimitiveID(1)}) } + +// TestAddPoint_DefaultsPriorityAndStyle verifies add Point Defaults Priority And Style. +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) +} + +// TestAddCircle_DefaultsPriorityAndStyle verifies add Circle Defaults Priority And Style. +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) +} + +// TestAddLine_DefaultsPriorityAndStyle verifies add Line Defaults Priority And Style. +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) +} + +// TestAddStyleLine_ThenUseStyleID verifies add Style Line Then Use Style ID. +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) +} + +// TestAddPoint_WithOverride_CreatesDerivedStyle verifies add Point With Override Creates Derived Style. +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) +} + +// TestExplicitStyleID_WinsOverOverride verifies explicit Style ID Wins Over Override. +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) +} + +// TestWorldPrimitiveID_ReusesFreedIDs verifies world Primitive ID Reuses Freed I Ds. +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) +} + +// TestWorldRemove_UnknownID verifies world Remove Unknown ID. +func TestWorldRemove_UnknownID(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + err := w.Remove(12345) + require.ErrorIs(t, err, errNoSuchObject) +} + +type gridCell struct { + Row int + Col int +} + +func newTestWorld(wReal, hReal int) *World { + w := NewWorld(wReal, hReal) + w.SetCircleRadiusScaleFp(SCALE) + return w +} + +func countObjectInGrid(g *World, id PrimitiveID) int { + count := 0 + for row := range g.grid { + for col := range g.grid[row] { + for _, item := range g.grid[row][col] { + if item.ID() == id { + count++ + } + } + } + } + return count +} + +func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool { + for _, item := range g.grid[row][col] { + if item.ID() == id { + return true + } + } + return false +} + +// TestViewportPxToWorldFixed verifies viewport Px To World Fixed. +func TestViewportPxToWorldFixed(t *testing.T) { + tests := []struct { + name string + viewportWidthPx int + viewportHeightPx int + cameraZoom int + wantWidth int + wantHeight int + }{ + { + name: "zoom 1.0", + viewportWidthPx: 500, + viewportHeightPx: 400, + cameraZoom: SCALE, + wantWidth: 500 * SCALE, + wantHeight: 400 * SCALE, + }, + { + name: "zoom 2.0", + viewportWidthPx: 500, + viewportHeightPx: 400, + cameraZoom: 2 * SCALE, + wantWidth: 250 * SCALE, + wantHeight: 200 * SCALE, + }, + { + name: "zoom below 1.0", + viewportWidthPx: 550, + viewportHeightPx: 550, + cameraZoom: 917, + wantWidth: 599781, + wantHeight: 599781, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotW, gotH := viewportPxToWorldFixed(tt.viewportWidthPx, tt.viewportHeightPx, tt.cameraZoom) + require.Equal(t, tt.wantWidth, gotW) + require.Equal(t, tt.wantHeight, gotH) + }) + } +} + +// TestSplitByWrap_ZeroOrNegativeSizeReturnsNil verifies split By Wrap Zero Or Negative Size Returns Nil. +func TestSplitByWrap_ZeroOrNegativeSizeReturnsNil(t *testing.T) { + tests := []struct { + name string + minX, maxX int + minY, maxY int + }{ + { + name: "zero width", + minX: 100, maxX: 100, + minY: 50, maxY: 100, + }, + { + name: "zero height", + minX: 100, maxX: 200, + minY: 50, maxY: 50, + }, + { + name: "negative width", + minX: 200, maxX: 100, + minY: 50, maxY: 100, + }, + { + name: "negative height", + minX: 100, maxX: 200, + minY: 100, maxY: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rects := splitByWrap(600, 400, tt.minX, tt.maxX, tt.minY, tt.maxY) + require.Nil(t, rects) + }) + } +} + +// TestSplitByWrap_XWrapUsesWorldWidth verifies split By Wrap X Wrap Uses World Width. +func TestSplitByWrap_XWrapUsesWorldWidth(t *testing.T) { + rects := splitByWrap( + 600, 400, + 500, 650, + 50, 100, + ) + + require.Len(t, rects, 2) + require.Equal(t, Rect{minX: 500, maxX: 600, minY: 50, maxY: 100}, rects[0]) + require.Equal(t, Rect{minX: 0, maxX: 50, minY: 50, maxY: 100}, rects[1]) +} + +// TestSplitByWrap_YWrapUsesWorldHeight verifies split By Wrap Y Wrap Uses World Height. +func TestSplitByWrap_YWrapUsesWorldHeight(t *testing.T) { + rects := splitByWrap( + 600, 400, + 50, 100, + 350, 450, + ) + + require.Len(t, rects, 2) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 350, maxY: 400}, rects[0]) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 50}, rects[1]) +} + +// TestSplitByWrap_XAndYWrap verifies split By Wrap X And Y Wrap. +func TestSplitByWrap_XAndYWrap(t *testing.T) { + rects := splitByWrap( + 600, 400, + 550, 650, + 350, 450, + ) + + require.Len(t, rects, 4) + require.ElementsMatch(t, []Rect{ + {minX: 550, maxX: 600, minY: 350, maxY: 400}, + {minX: 550, maxX: 600, minY: 0, maxY: 50}, + {minX: 0, maxX: 50, minY: 350, maxY: 400}, + {minX: 0, maxX: 50, minY: 0, maxY: 50}, + }, rects) +} + +// TestSplitByWrap_NoWrapInsideWorld verifies split By Wrap No Wrap Inside World. +func TestSplitByWrap_NoWrapInsideWorld(t *testing.T) { + rects := splitByWrap( + 600, 400, + 100, 200, + 50, 100, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 100, maxX: 200, minY: 50, maxY: 100}, rects[0]) +} + +// TestSplitByWrap_FullWorldCoverageOnEqualWidth verifies split By Wrap Full World Coverage On Equal Width. +func TestSplitByWrap_FullWorldCoverageOnEqualWidth(t *testing.T) { + rects := splitByWrap( + 600, 400, + 0, 600, + 50, 100, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 0, maxX: 600, minY: 50, maxY: 100}, rects[0]) +} + +// TestSplitByWrap_FullWorldCoverageOnEqualHeight verifies split By Wrap Full World Coverage On Equal Height. +func TestSplitByWrap_FullWorldCoverageOnEqualHeight(t *testing.T) { + rects := splitByWrap( + 600, 400, + 50, 100, + 0, 400, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 400}, rects[0]) +} + +// TestSplitByWrap_FullWorldCoverageOnBothAxes verifies split By Wrap Full World Coverage On Both Axes. +func TestSplitByWrap_FullWorldCoverageOnBothAxes(t *testing.T) { + rects := splitByWrap( + 600, 400, + 0, 600, + 0, 400, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 0, maxX: 600, minY: 0, maxY: 400}, rects[0]) +} + +// TestWorldToCell verifies world To Cell. +func TestWorldToCell(t *testing.T) { + tests := []struct { + name string + value int + worldSize int + cells int + cellSize int + want int + }{ + { + name: "simple inside world", + value: 150, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 1, + }, + { + name: "negative wraps to last cell", + value: -1, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 5, + }, + { + name: "exact world size wraps to zero", + value: 600, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 0, + }, + { + name: "large positive wraps correctly", + value: 650, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 0, + }, + { + name: "last in-range value lands in last cell", + value: 599, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 5, + }, + {name: "first cell", value: 0, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, + {name: "middle cell", value: 2500, worldSize: 10000, cells: 5, cellSize: 2000, want: 1}, + {name: "last exact world point wraps to zero", value: 10000, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, + {name: "negative wraps to last", value: -1, worldSize: 10000, cells: 5, cellSize: 2000, want: 4}, + {name: "partial last cell is clamped", value: 9999, worldSize: 10000, cells: 4, cellSize: 3000, want: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := worldToCell(tt.value, tt.worldSize, tt.cells, tt.cellSize) + require.Equal(t, tt.want, got) + }) + } +} + +// TestResetGrid_UsesWidthForColsAndHeightForRows verifies reset Grid Uses Width For Cols And Height For Rows. +func TestResetGrid_UsesWidthForColsAndHeightForRows(t *testing.T) { + g := newTestWorld(600, 400) + + g.resetGrid(100 * SCALE) + + require.Equal(t, 6, g.cols) + require.Equal(t, 4, g.rows) + require.Len(t, g.grid, 4) + require.Len(t, g.grid[0], 6) +} + +// TestIndexPoint verifies index Point. +func TestIndexPoint(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + p := Point{ + Id: id, + X: 150 * SCALE, + Y: 250 * SCALE, + } + + g.indexObject(p) + + require.True(t, hasObjectInCell(g, 2, 1, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +// TestIndexPoint_WrapsNegativeCoordinates verifies index Point Wraps Negative Coordinates. +func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + p := Point{ + Id: id, + X: -1, + Y: -1, + } + + g.indexObject(p) + + require.True(t, hasObjectInCell(g, 5, 5, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +// TestIndexCircle_WrapsAcrossLeftAndTopEdges verifies index Circle Wraps Across Left And Top Edges. +func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + c := Circle{ + Id: id, + X: 50 * SCALE, + Y: 50 * SCALE, + Radius: 75 * SCALE, + } + + g.indexObject(c) + + // The circle spans [-25..125] on both axes. + // It must appear both near zero and near the wrapped end. + require.True(t, hasObjectInCell(g, 0, 0, id)) + require.True(t, hasObjectInCell(g, 0, 5, id)) + require.True(t, hasObjectInCell(g, 5, 0, id)) + require.True(t, hasObjectInCell(g, 5, 5, id)) + + // It also extends into the next cells near the origin. + require.True(t, hasObjectInCell(g, 0, 1, id)) + require.True(t, hasObjectInCell(g, 1, 0, id)) + require.True(t, hasObjectInCell(g, 1, 1, id)) +} + +// TestIndexCircle_NoWrap verifies index Circle No Wrap. +func TestIndexCircle_NoWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + c := Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + } + + g.indexObject(c) + + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.True(t, hasObjectInCell(g, 2, 3, id)) + require.True(t, hasObjectInCell(g, 3, 2, id)) + require.True(t, hasObjectInCell(g, 3, 3, id)) +} + +// TestIndexCircle_CoversWholeWorldWhenLargerThanWorld verifies index Circle Covers Whole World When Larger Than World. +func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + c := Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 400 * SCALE, + } + + g.indexObject(c) + + for row := 0; row < g.rows; row++ { + for col := 0; col < g.cols; col++ { + require.Truef(t, hasObjectInCell(g, row, col, id), "missing object in row=%d col=%d", row, col) + } + } +} + +// TestIndexLine_HorizontalWrap verifies index Line Horizontal Wrap. +func TestIndexLine_HorizontalWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 590 * SCALE, + Y1: 200 * SCALE, + X2: 10 * SCALE, + Y2: 200 * SCALE, + } + + g.indexObject(l) + + // The shortest torus representation crosses the right/left border. + require.True(t, hasObjectInCell(g, 2, 5, id)) + require.True(t, hasObjectInCell(g, 2, 0, id)) +} + +// TestIndexLine_VerticalWrap verifies index Line Vertical Wrap. +func TestIndexLine_VerticalWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 590 * SCALE, + X2: 200 * SCALE, + Y2: 10 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 5, 2, id)) + require.True(t, hasObjectInCell(g, 0, 2, id)) +} + +// TestIndexLine_DiagonalWrapBothAxes verifies index Line Diagonal Wrap Both Axes. +func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 590 * SCALE, + Y1: 590 * SCALE, + X2: 10 * SCALE, + Y2: 10 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 5, 5, id)) + require.True(t, hasObjectInCell(g, 0, 0, id)) +} + +// TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes verifies index Line Horizontal No Wrap Degenerate B Box Still Indexes. +func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 100 * SCALE, + Y1: 200 * SCALE, + X2: 300 * SCALE, + Y2: 200 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is half-open: [100,300). + // Therefore it occupies columns 1 and 2, but not column 3. + require.True(t, hasObjectInCell(g, 2, 1, id)) + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.False(t, hasObjectInCell(g, 2, 3, id)) +} + +// TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes verifies index Line Vertical No Wrap Degenerate B Box Still Indexes. +func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 200 * SCALE, + Y2: 300 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is half-open: [100,300). + // Therefore it occupies rows 1 and 2, but not row 3. + require.True(t, hasObjectInCell(g, 1, 2, id)) + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.False(t, hasObjectInCell(g, 3, 2, id)) +} + +// TestIndexLine_ZeroLengthIndexesSingleCell verifies index Line Zero Length Indexes Single Cell. +func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 250 * SCALE, + Y1: 350 * SCALE, + X2: 250 * SCALE, + Y2: 350 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 3, 2, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +// TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval verifies index Line Exactly On Cell Boundary Uses Half Open Interval. +func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 400 * SCALE, + Y2: 100 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is [200,400), so it must occupy columns 2 and 3 only. + require.True(t, hasObjectInCell(g, 1, 2, id)) + require.True(t, hasObjectInCell(g, 1, 3, id)) + require.False(t, hasObjectInCell(g, 1, 4, id)) +} + +func collectOccupiedCells(g *World, id PrimitiveID) []gridCell { + var cells []gridCell + for row := range g.grid { + for col := range g.grid[row] { + for _, item := range g.grid[row][col] { + if item.ID() == id { + cells = append(cells, gridCell{Row: row, Col: col}) + break + } + } + } + } + return cells +} + +func allGridCells(rows, cols int) []gridCell { + cells := make([]gridCell, 0, rows*cols) + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + cells = append(cells, gridCell{Row: row, Col: col}) + } + } + return cells +} + +func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) { + t.Helper() + + got := collectOccupiedCells(g, id) + + require.ElementsMatchf( + t, + want, + got, + "unexpected indexed cells for object %d", + id, + ) +} + +// TestIndexObject_Point_TableDriven verifies index Object Point Table Driven. +func TestIndexObject_Point_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Point + wantCells []gridCell + }{ + { + name: "point inside world", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: PrimitiveID(1), + X: 150 * SCALE, + Y: 250 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 1}, + }, + }, + { + name: "point wraps from negative coordinates to last cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: PrimitiveID(1), + X: -1, + Y: -1, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + }, + }, + { + name: "point exactly at world boundary wraps to zero cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: PrimitiveID(1), + X: 600 * SCALE, + Y: 600 * SCALE, + }, + wantCells: []gridCell{ + {Row: 0, Col: 0}, + }, + }, + { + name: "point on cell boundary belongs to that cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: PrimitiveID(1), + X: 200 * SCALE, + Y: 300 * SCALE, + }, + wantCells: []gridCell{ + {Row: 3, Col: 2}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +// TestIndexObject_Circle_TableDriven verifies index Object Circle Table Driven. +func TestIndexObject_Circle_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Circle + wantCells []gridCell + }{ + { + name: "circle without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 2}, + {Row: 2, Col: 3}, + {Row: 3, Col: 2}, + {Row: 3, Col: 3}, + }, + }, + { + name: "circle wraps across left and top edges", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 50 * SCALE, + Y: 50 * SCALE, + Radius: 75 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + {Row: 5, Col: 0}, + {Row: 5, Col: 1}, + {Row: 0, Col: 5}, + {Row: 0, Col: 0}, + {Row: 0, Col: 1}, + {Row: 1, Col: 5}, + {Row: 1, Col: 0}, + {Row: 1, Col: 1}, + }, + }, + { + name: "circle wraps across right edge only", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 575 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 5}, + {Row: 2, Col: 0}, + {Row: 3, Col: 5}, + {Row: 3, Col: 0}, + }, + }, + { + name: "circle wraps across bottom edge only", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 300 * SCALE, + Y: 575 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 2}, + {Row: 5, Col: 3}, + {Row: 0, Col: 2}, + {Row: 0, Col: 3}, + }, + }, + { + name: "circle larger than world covers the whole grid", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 400 * SCALE, + }, + wantCells: allGridCells(6, 6), + }, + { + name: "circle touching boundaries exactly uses half-open indexing", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: PrimitiveID(1), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 100 * SCALE, // bbox [200, 400) x [200, 400) + }, + wantCells: []gridCell{ + {Row: 2, Col: 2}, + {Row: 2, Col: 3}, + {Row: 3, Col: 2}, + {Row: 3, Col: 3}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +// TestIndexObject_Line_TableDriven verifies index Object Line Table Driven. +func TestIndexObject_Line_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Line + wantCells []gridCell + }{ + { + name: "horizontal line without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 100 * SCALE, + Y1: 200 * SCALE, + X2: 300 * SCALE, + Y2: 200 * SCALE, + }, + // Half-open interval [100,300), so only cols 1 and 2. + wantCells: []gridCell{ + {Row: 2, Col: 1}, + {Row: 2, Col: 2}, + }, + }, + { + name: "vertical line without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 200 * SCALE, + Y2: 300 * SCALE, + }, + // Half-open interval [100,300), so only rows 1 and 2. + wantCells: []gridCell{ + {Row: 1, Col: 2}, + {Row: 2, Col: 2}, + }, + }, + { + name: "horizontal line wraps across left right border", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 590 * SCALE, + Y1: 200 * SCALE, + X2: 10 * SCALE, + Y2: 200 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 5}, + {Row: 2, Col: 0}, + }, + }, + { + name: "vertical line wraps across top bottom border", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 200 * SCALE, + Y1: 590 * SCALE, + X2: 200 * SCALE, + Y2: 10 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 2}, + {Row: 0, Col: 2}, + }, + }, + { + name: "diagonal line wraps across both axes", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 590 * SCALE, + Y1: 590 * SCALE, + X2: 10 * SCALE, + Y2: 10 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + {Row: 5, Col: 0}, + {Row: 0, Col: 5}, + {Row: 0, Col: 0}, + }, + }, + { + name: "zero length line indexes a single cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 250 * SCALE, + Y1: 350 * SCALE, + X2: 250 * SCALE, + Y2: 350 * SCALE, + }, + wantCells: []gridCell{ + {Row: 3, Col: 2}, + }, + }, + { + name: "line exactly on cell boundaries follows half-open interval", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 400 * SCALE, + Y2: 100 * SCALE, + }, + // [200,400) => cols 2 and 3 only. + wantCells: []gridCell{ + {Row: 1, Col: 2}, + {Row: 1, Col: 3}, + }, + }, + { + name: "diagonal line without wrap indexes its full bbox footprint", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 100 * SCALE, + Y1: 100 * SCALE, + X2: 300 * SCALE, + Y2: 300 * SCALE, + }, + // Indexing is bbox-based, not raster-based. + // The bbox is [100,300) x [100,300), so four cells. + wantCells: []gridCell{ + {Row: 1, Col: 1}, + {Row: 1, Col: 2}, + {Row: 2, Col: 1}, + {Row: 2, Col: 2}, + }, + }, + { + name: "horizontal wrap exactly on borders still indexes both edge cells", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: PrimitiveID(1), + X1: 600 * SCALE, + Y1: 100 * SCALE, + X2: 0, + Y2: 100 * SCALE, + }, + // After wrapping both endpoints are equivalent to zero-width on the edge. + // The degenerate bbox expansion should still index the first cell only. + wantCells: []gridCell{ + {Row: 1, Col: 0}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +// TestIndexOnViewportChange_RebuildsGridAndIndexesObjects verifies index On Viewport Change Rebuilds Grid And Indexes Objects. +func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) { + g := newTestWorld(600, 400) + + pID := PrimitiveID(1) + cID := PrimitiveID(2) + lID := PrimitiveID(3) + + g.objects[pID] = Point{ + Id: pID, + X: 50 * SCALE, + Y: 50 * SCALE, + } + g.objects[cID] = Circle{ + Id: cID, + X: 300 * SCALE, + Y: 200 * SCALE, + Radius: 50 * SCALE, + } + g.objects[lID] = Line{ + Id: lID, + X1: 590 * SCALE, + Y1: 100 * SCALE, + X2: 10 * SCALE, + Y2: 100 * SCALE, + } + + g.IndexOnViewportChange(500, 300, 1.) + + require.Greater(t, g.cellSize, 0) + require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) + require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) + + require.Greaterf(t, countObjectInGrid(g, pID), 0, "point %s was not indexed", pID) + require.Greaterf(t, countObjectInGrid(g, cID), 0, "circle %s was not indexed", cID) + require.Greaterf(t, countObjectInGrid(g, lID), 0, "line %s was not indexed", lID) +} + +// TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld verifies index On Viewport Change Rebuilds Grid Shape For Non Square World. +func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) { + g := newTestWorld(600, 400) + g.IndexOnViewportChange(500, 300, 1.) + + require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) + require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) + require.Len(t, g.grid, g.rows) + require.Len(t, g.grid[0], g.cols) +} + +// TestIndexOnViewportChange_ReindexesAfterCellSizeChange verifies index On Viewport Change Reindexes After Cell Size Change. +func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) { + g := newTestWorld(600, 600) + + id := PrimitiveID(1) + g.objects[id] = Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + } + + g.IndexOnViewportChange(500, 500, 1.) + firstCellSize := g.cellSize + firstCount := countObjectInGrid(g, id) + + g.IndexOnViewportChange(200, 200, 1.) + secondCellSize := g.cellSize + secondCount := countObjectInGrid(g, id) + + require.NotEqual(t, firstCellSize, secondCellSize) + require.Greater(t, firstCount, 0) + require.Greater(t, secondCount, 0) + + if firstCellSize != secondCellSize && firstCount == secondCount { + t.Logf( + "cell size changed from %d to %d, but the indexed cell count happened to stay equal (%d)", + firstCellSize, + secondCellSize, + firstCount, + ) + } +} + +// TestPrimitiveIndexing_ErrorMessagesStayReadable verifies primitive Indexing Error Messages Stay Readable. +func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := PrimitiveID(1) + p := Point{ + Id: id, + X: 100 * SCALE, + Y: 100 * SCALE, + } + + g.indexObject(p) + + got := collectOccupiedCells(g, id) + require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id)) +} + +// TestPrimitiveIDs verifies primitive I Ds. +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) + } +} + +// TestLineMinMax verifies line Min Max. +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) + } +} + +// TestCircleBounds verifies circle Bounds. +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) + } +} + +// TestRender_CircleRadiusScale_AffectsRenderedRadiusPx verifies render Circle Radius Scale Affects Rendered Radius Px. +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") +} diff --git a/client/world/zoom.go b/client/world/zoom.go deleted file mode 100644 index 3f7b6bf..0000000 --- a/client/world/zoom.go +++ /dev/null @@ -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) -} diff --git a/client/world/zoom_test.go b/client/world/zoom_test.go deleted file mode 100644 index 1d1c4a6..0000000 --- a/client/world/zoom_test.go +++ /dev/null @@ -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) -}