world refactor
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package world
|
||||
|
||||
type PointClassID uint8
|
||||
|
||||
const (
|
||||
PointClassDefault PointClassID = iota
|
||||
PointClassTrackUnknown
|
||||
PointClassTrackIncoming
|
||||
PointClassTrackOutgoing
|
||||
)
|
||||
|
||||
type LineClassID uint8
|
||||
|
||||
const (
|
||||
LineClassDefault LineClassID = iota
|
||||
LineClassTrackIncoming
|
||||
LineCLassTrackOutgoing
|
||||
LineClassMeasurement
|
||||
)
|
||||
|
||||
type CircleClassID uint8
|
||||
|
||||
const (
|
||||
CircleClassDefault CircleClassID = iota
|
||||
CircleClassHome
|
||||
CircleClassAcquired
|
||||
CircleClassOccupied
|
||||
CircleClassFree
|
||||
)
|
||||
+261
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(40, 20)
|
||||
d := &GGDrawer{DC: dc}
|
||||
|
||||
// Fill background to white.
|
||||
d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
|
||||
// Configure stroke to red and draw first line.
|
||||
d.SetStrokeColor(color.RGBA{R: 255, A: 255})
|
||||
d.SetLineWidth(2)
|
||||
d.AddLine(2, 5, 38, 5)
|
||||
d.Stroke()
|
||||
|
||||
// Clear a rect in the middle with gray (must not affect stroke state).
|
||||
d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255})
|
||||
|
||||
// Draw second line WITHOUT reapplying stroke style; it must still be red.
|
||||
d.AddLine(2, 15, 38, 15)
|
||||
d.Stroke()
|
||||
|
||||
img := dc.Image()
|
||||
|
||||
// Sample a pixel from the second line (y ~15). We expect red channel dominates.
|
||||
r, g, b, a := img.At(20, 15).RGBA()
|
||||
require.Greater(t, a, uint32(0), "pixel must not be fully transparent")
|
||||
require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo")
|
||||
require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo")
|
||||
}
|
||||
+387
-4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
|
||||
type fakeClipRect struct {
|
||||
X, Y float64
|
||||
W, H float64
|
||||
}
|
||||
|
||||
// fakeDrawerState stores the active fake drawing state.
|
||||
// The state is copied on Save and restored on Restore.
|
||||
type fakeDrawerState struct {
|
||||
StrokeColor color.RGBA
|
||||
FillColor color.RGBA
|
||||
LineWidth float64
|
||||
Dashes []float64
|
||||
DashOffset float64
|
||||
Clips []fakeClipRect
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the state.
|
||||
func (s fakeDrawerState) clone() fakeDrawerState {
|
||||
out := s
|
||||
out.Dashes = append([]float64(nil), s.Dashes...)
|
||||
out.Clips = append([]fakeClipRect(nil), s.Clips...)
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeDrawerCommand is one recorded drawer call together with a snapshot
|
||||
// of the active fake drawing state at the moment of the call.
|
||||
type fakeDrawerCommand struct {
|
||||
Name string
|
||||
Args []float64
|
||||
StrokeColor color.RGBA
|
||||
FillColor color.RGBA
|
||||
LineWidth float64
|
||||
Dashes []float64
|
||||
DashOffset float64
|
||||
Clips []fakeClipRect
|
||||
}
|
||||
|
||||
// String returns a compact debug representation useful in assertion failures.
|
||||
func (c fakeDrawerCommand) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
|
||||
c.Name,
|
||||
c.Args,
|
||||
c.StrokeColor,
|
||||
c.FillColor,
|
||||
c.LineWidth,
|
||||
c.Dashes,
|
||||
c.DashOffset,
|
||||
c.Clips,
|
||||
)
|
||||
}
|
||||
|
||||
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
|
||||
// It records all calls and emulates stateful behavior, including nested
|
||||
// Save/Restore and clip reset semantics.
|
||||
type fakePrimitiveDrawer struct {
|
||||
commands []fakeDrawerCommand
|
||||
state fakeDrawerState
|
||||
stack []fakeDrawerState
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
|
||||
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
|
||||
|
||||
// rgbaColor converts any color.Color into a comparable RGBA value.
|
||||
func rgbaColor(c color.Color) color.RGBA {
|
||||
if c == nil {
|
||||
return color.RGBA{}
|
||||
}
|
||||
return color.RGBAModel.Convert(c).(color.RGBA)
|
||||
}
|
||||
|
||||
// snapshotCommand records one command together with the current state snapshot.
|
||||
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
|
||||
cmd := fakeDrawerCommand{
|
||||
Name: name,
|
||||
Args: append([]float64(nil), args...),
|
||||
StrokeColor: d.state.StrokeColor,
|
||||
FillColor: d.state.FillColor,
|
||||
LineWidth: d.state.LineWidth,
|
||||
Dashes: append([]float64(nil), d.state.Dashes...),
|
||||
DashOffset: d.state.DashOffset,
|
||||
Clips: append([]fakeClipRect(nil), d.state.Clips...),
|
||||
}
|
||||
d.commands = append(d.commands, cmd)
|
||||
}
|
||||
|
||||
// Save stores the current fake state.
|
||||
func (d *fakePrimitiveDrawer) Save() {
|
||||
d.stack = append(d.stack, d.state.clone())
|
||||
d.snapshotCommand("Save")
|
||||
}
|
||||
|
||||
// Restore restores the most recently saved fake state.
|
||||
func (d *fakePrimitiveDrawer) Restore() {
|
||||
if len(d.stack) == 0 {
|
||||
panic("fakePrimitiveDrawer: Restore without matching Save")
|
||||
}
|
||||
|
||||
d.state = d.stack[len(d.stack)-1]
|
||||
d.stack = d.stack[:len(d.stack)-1]
|
||||
d.snapshotCommand("Restore")
|
||||
}
|
||||
|
||||
// ResetClip clears the current fake clip stack.
|
||||
func (d *fakePrimitiveDrawer) ResetClip() {
|
||||
d.state.Clips = nil
|
||||
d.snapshotCommand("ResetClip")
|
||||
}
|
||||
|
||||
// ClipRect appends one clip rectangle to the current fake state.
|
||||
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
|
||||
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
|
||||
d.snapshotCommand("ClipRect", x, y, w, h)
|
||||
}
|
||||
|
||||
// SetStrokeColor sets the current fake stroke color.
|
||||
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
|
||||
d.state.StrokeColor = rgbaColor(c)
|
||||
d.snapshotCommand("SetStrokeColor")
|
||||
}
|
||||
|
||||
// SetFillColor sets the current fake fill color.
|
||||
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
|
||||
d.state.FillColor = rgbaColor(c)
|
||||
d.snapshotCommand("SetFillColor")
|
||||
}
|
||||
|
||||
// SetLineWidth sets the current fake line width.
|
||||
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
|
||||
d.state.LineWidth = width
|
||||
d.snapshotCommand("SetLineWidth", width)
|
||||
}
|
||||
|
||||
// SetDash sets the current fake dash pattern.
|
||||
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
|
||||
d.state.Dashes = append([]float64(nil), dashes...)
|
||||
d.snapshotCommand("SetDash", dashes...)
|
||||
}
|
||||
|
||||
// SetDashOffset sets the current fake dash offset.
|
||||
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
|
||||
d.state.DashOffset = offset
|
||||
d.snapshotCommand("SetDashOffset", offset)
|
||||
}
|
||||
|
||||
// AddPoint records a point path append command.
|
||||
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
|
||||
d.snapshotCommand("AddPoint", x, y, r)
|
||||
}
|
||||
|
||||
// AddLine records a line path append command.
|
||||
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
|
||||
d.snapshotCommand("AddLine", x1, y1, x2, y2)
|
||||
}
|
||||
|
||||
// AddCircle records a circle path append command.
|
||||
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
|
||||
d.snapshotCommand("AddCircle", cx, cy, r)
|
||||
}
|
||||
|
||||
// Stroke records a stroke finalization command.
|
||||
func (d *fakePrimitiveDrawer) Stroke() {
|
||||
d.snapshotCommand("Stroke")
|
||||
}
|
||||
|
||||
// Fill records a fill finalization command.
|
||||
func (d *fakePrimitiveDrawer) Fill() {
|
||||
d.snapshotCommand("Fill")
|
||||
}
|
||||
|
||||
// Commands returns a defensive copy of the recorded command log.
|
||||
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
|
||||
out := make([]fakeDrawerCommand, len(d.commands))
|
||||
copy(out, d.commands)
|
||||
return out
|
||||
}
|
||||
|
||||
// CommandNames returns only command names in call order.
|
||||
func (d *fakePrimitiveDrawer) CommandNames() []string {
|
||||
out := make([]string, 0, len(d.commands))
|
||||
for _, cmd := range d.commands {
|
||||
out = append(out, cmd.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CommandsByName returns all commands with the given name.
|
||||
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
|
||||
var out []fakeDrawerCommand
|
||||
for _, cmd := range d.commands {
|
||||
if cmd.Name == name {
|
||||
out = append(out, cmd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LastCommand returns the last recorded command and whether it exists.
|
||||
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
|
||||
if len(d.commands) == 0 {
|
||||
return fakeDrawerCommand{}, false
|
||||
}
|
||||
return d.commands[len(d.commands)-1], true
|
||||
}
|
||||
|
||||
// CurrentState returns a defensive copy of the current fake state.
|
||||
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
|
||||
return d.state.clone()
|
||||
}
|
||||
|
||||
// SaveDepth returns the current Save/Restore nesting depth.
|
||||
func (d *fakePrimitiveDrawer) SaveDepth() int {
|
||||
return len(d.stack)
|
||||
}
|
||||
|
||||
// ResetLog clears only the command log and keeps the current state intact.
|
||||
func (d *fakePrimitiveDrawer) ResetLog() {
|
||||
d.commands = nil
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
|
||||
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
|
||||
// Store as a command; tests usually only care that it was called.
|
||||
d.snapshotCommand("ClearAllTo")
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
|
||||
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) {
|
||||
d.snapshotCommand("DrawImage", float64(x), float64(y))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
|
||||
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
|
||||
}
|
||||
func (d *fakePrimitiveDrawer) Reset() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.commands = d.commands[:0]
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
type benchBgTheme struct {
|
||||
img image.Image
|
||||
anchor BackgroundAnchorMode
|
||||
tileMode BackgroundTileMode
|
||||
scaleMode BackgroundScaleMode
|
||||
}
|
||||
|
||||
func (t benchBgTheme) ID() string { return "benchbg" }
|
||||
func (t benchBgTheme) Name() string { return "benchbg" }
|
||||
|
||||
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
|
||||
|
||||
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
|
||||
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
||||
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
|
||||
|
||||
func (t benchBgTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
||||
}
|
||||
func (t benchBgTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t benchBgTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1200, 800, 1.0)
|
||||
|
||||
// Some primitives to keep it realistic but not dominant.
|
||||
for i := 0; i < 200; i++ {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
dc := gg.NewContext(1200, 800)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{
|
||||
AllowShiftOnly: false,
|
||||
CoalesceUpdates: false,
|
||||
MaxCatchUpAreaPx: 0,
|
||||
RenderBudgetMs: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Initial render (commit state).
|
||||
_ = w.Render(drawer, params)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
params.CameraXWorldFp += 1 * SCALE
|
||||
_ = w.Render(drawer, params)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
|
||||
}
|
||||
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
|
||||
}
|
||||
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
|
||||
}
|
||||
|
||||
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1200, 800, 1.0)
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
// Background tile (RGBA) — typical texture size.
|
||||
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
|
||||
// Make it semi-transparent so draw.Over has real work.
|
||||
for y := 0; y < 96; y++ {
|
||||
for x := 0; x < 96; x++ {
|
||||
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
|
||||
}
|
||||
}
|
||||
|
||||
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
|
||||
|
||||
dc := gg.NewContext(1200, 800)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{
|
||||
AllowShiftOnly: false,
|
||||
CoalesceUpdates: false,
|
||||
MaxCatchUpAreaPx: 0,
|
||||
RenderBudgetMs: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_ = w.Render(drawer, params)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
params.CameraXWorldFp += 1 * SCALE
|
||||
_ = w.Render(drawer, params)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
func effectiveHitSlopPx(hitSlopPx int, def int) int {
|
||||
if hitSlopPx > 0 {
|
||||
return hitSlopPx
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func alphaNonZero(c color.Color) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
_, _, _, a := c.RGBA()
|
||||
return a != 0
|
||||
}
|
||||
|
||||
func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
|
||||
slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx)
|
||||
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
|
||||
|
||||
var dx, dy int64
|
||||
if allowWrap {
|
||||
dx = shortestTorusDelta(p.X, cx, worldW)
|
||||
dy = shortestTorusDelta(p.Y, cy, worldH)
|
||||
} else {
|
||||
dx = int64(cx - p.X)
|
||||
dy = int64(cy - p.Y)
|
||||
}
|
||||
|
||||
// Point is treated as a small disc: dist <= slop.
|
||||
ds := distSqU128(dx, dy)
|
||||
rs := sqU128Int64(int64(slopW))
|
||||
|
||||
if u128Cmp(ds, rs) <= 0 {
|
||||
return Hit{
|
||||
ID: p.Id,
|
||||
Kind: KindPoint,
|
||||
Priority: p.Priority,
|
||||
StyleID: p.StyleID,
|
||||
DistanceSq: ds,
|
||||
X: p.X,
|
||||
Y: p.Y,
|
||||
}, true
|
||||
}
|
||||
return Hit{}, false
|
||||
}
|
||||
|
||||
func hitCircle(c Circle, effRadiusFp int, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
|
||||
slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx)
|
||||
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
|
||||
|
||||
fillVisible := alphaNonZero(style.FillColor)
|
||||
|
||||
// Determine if circle is point-like at current zoom.
|
||||
// IMPORTANT: point-like disc behavior applies only for filled circles.
|
||||
rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp)
|
||||
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
|
||||
|
||||
var dx, dy int64
|
||||
if allowWrap {
|
||||
dx = shortestTorusDelta(c.X, cx, worldW)
|
||||
dy = shortestTorusDelta(c.Y, cy, worldH)
|
||||
} else {
|
||||
dx = int64(cx - c.X)
|
||||
dy = int64(cy - c.Y)
|
||||
}
|
||||
|
||||
ds := distSqU128(dx, dy)
|
||||
|
||||
// Filled + point-like: treat as a disc with minimum visible radius + slop.
|
||||
if pointLike {
|
||||
// Treat as a disc with minimum visible radius in px.
|
||||
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
||||
effR := minRW
|
||||
if effRadiusFp > effR {
|
||||
effR = effRadiusFp
|
||||
}
|
||||
r := effR + slopW
|
||||
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
||||
return Hit{
|
||||
ID: c.Id,
|
||||
Kind: KindCircle,
|
||||
Priority: c.Priority,
|
||||
StyleID: c.StyleID,
|
||||
DistanceSq: ds,
|
||||
X: c.X,
|
||||
Y: c.Y,
|
||||
Radius: effRadiusFp,
|
||||
}, true
|
||||
}
|
||||
return Hit{}, false
|
||||
}
|
||||
|
||||
// Filled circle: hit-test by disc (surface).
|
||||
if fillVisible {
|
||||
r := effRadiusFp + slopW
|
||||
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
||||
return Hit{
|
||||
ID: c.Id,
|
||||
Kind: KindCircle,
|
||||
Priority: c.Priority,
|
||||
StyleID: c.StyleID,
|
||||
DistanceSq: ds,
|
||||
X: c.X,
|
||||
Y: c.Y,
|
||||
Radius: effRadiusFp,
|
||||
}, true
|
||||
}
|
||||
return Hit{}, false
|
||||
}
|
||||
|
||||
// Stroke-only circle: ring hit, but NEVER at exact center.
|
||||
// For very small circles, expand the effective radius to a minimum visible size
|
||||
// so that ring selection remains practical, while still excluding center.
|
||||
effR := effRadiusFp
|
||||
if rPx < CirclePointLikeMinRadiusPx {
|
||||
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
||||
if minRW > effR {
|
||||
effR = minRW
|
||||
}
|
||||
}
|
||||
|
||||
low := effR - slopW
|
||||
// IMPORTANT: center must not hit for stroke-only circles.
|
||||
if low < 1 {
|
||||
low = 1
|
||||
}
|
||||
high := effR + slopW
|
||||
|
||||
lowSq := sqU128Int64(int64(low))
|
||||
highSq := sqU128Int64(int64(high))
|
||||
|
||||
if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 {
|
||||
return Hit{
|
||||
ID: c.Id,
|
||||
Kind: KindCircle,
|
||||
Priority: c.Priority,
|
||||
StyleID: c.StyleID,
|
||||
DistanceSq: ds,
|
||||
X: c.X,
|
||||
Y: c.Y,
|
||||
Radius: effRadiusFp,
|
||||
}, true
|
||||
}
|
||||
return Hit{}, false
|
||||
}
|
||||
|
||||
func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
|
||||
slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx)
|
||||
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
|
||||
|
||||
// For wrap: compare against torus-shortest representation (same as rendering).
|
||||
// We test all segments produced by torusShortestLineSegments and take the best (min distance).
|
||||
segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
|
||||
if allowWrap {
|
||||
segs = torusShortestLineSegments(l, worldW, worldH)
|
||||
}
|
||||
|
||||
best := Hit{}
|
||||
found := false
|
||||
|
||||
for _, s := range segs {
|
||||
ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2))
|
||||
|
||||
// Check ds <= slopW^2
|
||||
if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 {
|
||||
h := Hit{
|
||||
ID: l.Id,
|
||||
Kind: KindLine,
|
||||
Priority: l.Priority,
|
||||
StyleID: l.StyleID,
|
||||
DistanceSq: ds,
|
||||
X1: l.X1,
|
||||
Y1: l.Y1,
|
||||
X2: l.X2,
|
||||
Y2: l.Y2,
|
||||
}
|
||||
if !found || hitLess(h, best) {
|
||||
best = h
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best, found
|
||||
}
|
||||
|
||||
// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons.
|
||||
func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 {
|
||||
abx := bx - ax
|
||||
aby := by - ay
|
||||
apx := px - ax
|
||||
apy := py - ay
|
||||
|
||||
// Degenerate segment => distance to point A.
|
||||
if abx == 0 && aby == 0 {
|
||||
return distSqU128(apx, apy)
|
||||
}
|
||||
|
||||
dot := apx*abx + apy*aby
|
||||
if dot <= 0 {
|
||||
return distSqU128(apx, apy)
|
||||
}
|
||||
|
||||
abLen2 := abx*abx + aby*aby
|
||||
if dot >= abLen2 {
|
||||
bpx := px - bx
|
||||
bpy := py - by
|
||||
return distSqU128(bpx, bpy)
|
||||
}
|
||||
|
||||
// Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers.
|
||||
// Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain
|
||||
// would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use
|
||||
// dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64.
|
||||
//
|
||||
// cross = AP x AB
|
||||
cross := apx*aby - apy*abx
|
||||
|
||||
// cross^2 fits in u128, abLen2 fits in int64.
|
||||
c2 := sqU128Int64(cross)
|
||||
return u128DivByU64(c2, uint64(abLen2))
|
||||
}
|
||||
|
||||
// u128DivByU64 returns floor(a / d) where d>0, producing u128 result.
|
||||
// Here we only need it for tie-breaking (monotonic).
|
||||
func u128DivByU64(a u128, d uint64) u128 {
|
||||
if d == 0 {
|
||||
panic("u128DivByU64: divide by zero")
|
||||
}
|
||||
// Simple long division for 128/64 -> 128 quotient (but high part will be small here).
|
||||
// We compute using two-step: divide high then combine.
|
||||
qHi := a.hi / d
|
||||
rHi := a.hi % d
|
||||
|
||||
// Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d.
|
||||
// Use bits.Div64 for (hi, lo)/d.
|
||||
qLo, _ := bits.Div64(rHi, a.lo, d)
|
||||
|
||||
return u128{hi: qHi, lo: qLo}
|
||||
}
|
||||
+150
-2
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRender_SortsByPriorityWithinTile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Same tile. Priorities deliberately mixed.
|
||||
_, err := w.AddCircle(5, 5, 1, CircleWithPriority(500))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = w.AddPoint(5, 6, PointWithPriority(300))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
// default: wrap on
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
// We verify the first occurrence of each primitive kind follows priority order.
|
||||
// Since each object is drawn with Add* + Fill/Stroke immediately, order should match.
|
||||
cmds := d.Commands()
|
||||
firstLine := indexOfFirst(cmds, "AddLine")
|
||||
firstCircle := indexOfFirst(cmds, "AddCircle")
|
||||
firstPoint := indexOfFirst(cmds, "AddPoint")
|
||||
|
||||
require.NotEqual(t, -1, firstLine)
|
||||
require.NotEqual(t, -1, firstCircle)
|
||||
require.NotEqual(t, -1, firstPoint)
|
||||
|
||||
require.Less(t, firstLine, firstPoint)
|
||||
require.Less(t, firstPoint, firstCircle) // 300 before 500
|
||||
}
|
||||
|
||||
func indexOfFirst(cmds []fakeDrawerCommand, name string) int {
|
||||
for i, c := range cmds {
|
||||
if c.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
+1393
-3
File diff suppressed because it is too large
Load Diff
@@ -1,190 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
)
|
||||
|
||||
func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) {
|
||||
if gd, ok := drawer.(*GGDrawer); ok {
|
||||
if gd.drawBackgroundFast(w, params, rect) {
|
||||
return
|
||||
}
|
||||
}
|
||||
th := w.Theme()
|
||||
bgImg := th.BackgroundImage()
|
||||
if bgImg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
canvasW := params.CanvasWidthPx()
|
||||
canvasH := params.CanvasHeightPx()
|
||||
|
||||
// Clamp rect to canvas.
|
||||
if rect.W <= 0 || rect.H <= 0 {
|
||||
return
|
||||
}
|
||||
if rect.X < 0 {
|
||||
rect.W += rect.X
|
||||
rect.X = 0
|
||||
}
|
||||
if rect.Y < 0 {
|
||||
rect.H += rect.Y
|
||||
rect.Y = 0
|
||||
}
|
||||
if rect.X+rect.W > canvasW {
|
||||
rect.W = canvasW - rect.X
|
||||
}
|
||||
if rect.Y+rect.H > canvasH {
|
||||
rect.H = canvasH - rect.Y
|
||||
}
|
||||
if rect.W <= 0 || rect.H <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
imgB := bgImg.Bounds()
|
||||
imgW := imgB.Dx()
|
||||
imgH := imgB.Dy()
|
||||
if imgW <= 0 || imgH <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tileMode := th.BackgroundTileMode()
|
||||
anchor := th.BackgroundAnchorMode()
|
||||
scaleMode := th.BackgroundScaleMode()
|
||||
|
||||
// Compute scaled tile size.
|
||||
tileW, tileH := backgroundScaledSize(imgW, imgH, canvasW, canvasH, scaleMode)
|
||||
if tileW <= 0 || tileH <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
|
||||
|
||||
drawer.Save()
|
||||
drawer.ResetClip()
|
||||
drawer.ClipRect(float64(rect.X), float64(rect.Y), float64(rect.W), float64(rect.H))
|
||||
|
||||
switch tileMode {
|
||||
case BackgroundTileNone:
|
||||
// Center image within full canvas (not within rect), then clip handles partial.
|
||||
// This is important so that dirty redraw matches full redraw.
|
||||
x := (canvasW-tileW)/2 + offX
|
||||
y := (canvasH-tileH)/2 + offY
|
||||
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
|
||||
|
||||
case BackgroundTileRepeat:
|
||||
originX := offX
|
||||
originY := offY
|
||||
|
||||
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
|
||||
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
|
||||
|
||||
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
|
||||
for xx := startX; xx < rect.X+rect.W; xx += tileW {
|
||||
drawBackgroundOne(drawer, bgImg, xx, yy, imgW, imgH, tileW, tileH, scaleMode)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Fallback: behave like none.
|
||||
x := (canvasW-tileW)/2 + offX
|
||||
y := (canvasH-tileH)/2 + offY
|
||||
drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
|
||||
}
|
||||
|
||||
drawer.Restore()
|
||||
}
|
||||
|
||||
func drawBackgroundOne(drawer PrimitiveDrawer, img image.Image, x, y, srcW, srcH, dstW, dstH int, scaleMode BackgroundScaleMode) {
|
||||
if scaleMode == BackgroundScaleNone && dstW == srcW && dstH == srcH {
|
||||
drawer.DrawImage(img, x, y)
|
||||
return
|
||||
}
|
||||
// For Fit/Fill, or if dst size differs, draw scaled.
|
||||
drawer.DrawImageScaled(img, x, y, dstW, dstH)
|
||||
}
|
||||
|
||||
// backgroundScaledSize computes uniform scaled destination size for the background image.
|
||||
// For None: returns source size.
|
||||
// For Fit: fits inside canvas.
|
||||
// For Fill: covers canvas.
|
||||
func backgroundScaledSize(srcW, srcH, canvasW, canvasH int, mode BackgroundScaleMode) (int, int) {
|
||||
if srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case BackgroundScaleNone:
|
||||
return srcW, srcH
|
||||
|
||||
case BackgroundScaleFit, BackgroundScaleFill:
|
||||
// Uniform scale: choose ratio based on min/max.
|
||||
// Use integer math to avoid float; keep it stable across frames.
|
||||
// We compute scale as rational and then round destination size.
|
||||
// Let scale = canvasW/srcW vs canvasH/srcH.
|
||||
// Fit uses min(scaleW, scaleH). Fill uses max(scaleW, scaleH).
|
||||
//
|
||||
// We'll compute dstW = round(srcW*scale), dstH = round(srcH*scale).
|
||||
// Using float64 here is acceptable: this is UI-only and deterministic enough, and we already use gg float.
|
||||
scaleW := float64(canvasW) / float64(srcW)
|
||||
scaleH := float64(canvasH) / float64(srcH)
|
||||
|
||||
scale := scaleW
|
||||
if mode == BackgroundScaleFit {
|
||||
if scaleH < scale {
|
||||
scale = scaleH
|
||||
}
|
||||
} else {
|
||||
if scaleH > scale {
|
||||
scale = scaleH
|
||||
}
|
||||
}
|
||||
|
||||
dstW := int(scale*float64(srcW) + 0.5)
|
||||
dstH := int(scale*float64(srcH) + 0.5)
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
return dstW, dstH
|
||||
|
||||
default:
|
||||
return srcW, srcH
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundAnchorOffsetPx computes a stable pixel offset for background anchoring.
|
||||
// - Viewport anchor: offset is always 0 (background fixed to viewport/canvas pixels).
|
||||
// - World anchor: offset depends on camera world position and zoom so that background moves with pan.
|
||||
func (w *World) backgroundAnchorOffsetPx(params RenderParams, tileW, tileH int, anchor BackgroundAnchorMode) (int, int) {
|
||||
if anchor == BackgroundAnchorViewport {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
zoomFp, err := params.CameraZoomFp()
|
||||
if err != nil || zoomFp <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
canvasW := params.CanvasWidthPx()
|
||||
canvasH := params.CanvasHeightPx()
|
||||
|
||||
spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
|
||||
spanH := PixelSpanToWorldFixed(canvasH, zoomFp)
|
||||
|
||||
worldLeft := params.CameraXWorldFp - spanW/2
|
||||
worldTop := params.CameraYWorldFp - spanH/2
|
||||
|
||||
pxX := worldSpanFixedToCanvasPx(worldLeft, zoomFp)
|
||||
pxY := worldSpanFixedToCanvasPx(worldTop, zoomFp)
|
||||
|
||||
if tileW > 0 {
|
||||
pxX = -wrap(pxX, tileW)
|
||||
}
|
||||
if tileH > 0 {
|
||||
pxY = -wrap(pxY, tileH)
|
||||
}
|
||||
return pxX, pxY
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bgOffsetScaleTheme struct {
|
||||
img image.Image
|
||||
anchor BackgroundAnchorMode
|
||||
}
|
||||
|
||||
func (t bgOffsetScaleTheme) ID() string { return "bgoffset" }
|
||||
func (t bgOffsetScaleTheme) Name() string { return "bgoffset" }
|
||||
|
||||
func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img }
|
||||
|
||||
func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
|
||||
func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
||||
func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
|
||||
|
||||
func (t bgOffsetScaleTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
||||
}
|
||||
func (t bgOffsetScaleTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t bgOffsetScaleTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(20, 20)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4
|
||||
w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 8,
|
||||
ViewportHeightPx: 8,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
// First render.
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params))
|
||||
|
||||
minX1, minY1 := minDrawImageXY(t, d1)
|
||||
require.Equal(t, -1, minX1)
|
||||
require.Equal(t, -1, minY1)
|
||||
|
||||
// Pan camera by +1 world unit along both axes (zoom=1 => 1px).
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
params2.CameraYWorldFp += 1 * SCALE
|
||||
|
||||
// Force full redraw to make this test independent of incremental pipeline.
|
||||
w.ForceFullRedrawNext()
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params2))
|
||||
|
||||
minX2, minY2 := minDrawImageXY(t, d2)
|
||||
|
||||
// With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size).
|
||||
require.Equal(t, -2, minX2)
|
||||
require.Equal(t, -2, minY2)
|
||||
}
|
||||
|
||||
func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(20, 20)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 8,
|
||||
ViewportHeightPx: 8,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params))
|
||||
|
||||
minX1, minY1 := minDrawImageXY(t, d1)
|
||||
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
params2.CameraYWorldFp += 1 * SCALE
|
||||
|
||||
w.ForceFullRedrawNext()
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params2))
|
||||
|
||||
minX2, minY2 := minDrawImageXY(t, d2)
|
||||
|
||||
// With viewport anchoring, tiling origin is fixed (no camera dependency).
|
||||
require.Equal(t, minX1, minX2)
|
||||
require.Equal(t, minY1, minY2)
|
||||
}
|
||||
|
||||
func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) {
|
||||
t.Helper()
|
||||
|
||||
cmds := d.CommandsByName("DrawImage")
|
||||
require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling")
|
||||
|
||||
minX := int(cmds[0].Args[0])
|
||||
minY := int(cmds[0].Args[1])
|
||||
|
||||
for _, c := range cmds[1:] {
|
||||
x := int(c.Args[0])
|
||||
y := int(c.Args[1])
|
||||
if x < minX {
|
||||
minX = x
|
||||
}
|
||||
if y < minY {
|
||||
minY = y
|
||||
}
|
||||
}
|
||||
return minX, minY
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bgOffsetTheme struct {
|
||||
img image.Image
|
||||
scaleMode BackgroundScaleMode
|
||||
}
|
||||
|
||||
func (t bgOffsetTheme) ID() string { return "bgscale" }
|
||||
func (t bgOffsetTheme) Name() string { return "bgscale" }
|
||||
|
||||
func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img }
|
||||
|
||||
func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
||||
func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
||||
func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
|
||||
|
||||
func (t bgOffsetTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
||||
}
|
||||
func (t bgOffsetTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t bgOffsetTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
require.NotEmpty(t, d.CommandsByName("DrawImage"))
|
||||
require.Empty(t, d.CommandsByName("DrawImageScaled"))
|
||||
}
|
||||
|
||||
func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bgScaleTheme struct {
|
||||
img image.Image
|
||||
scaleMode BackgroundScaleMode
|
||||
}
|
||||
|
||||
func (t bgScaleTheme) ID() string { return "bgscale" }
|
||||
func (t bgScaleTheme) Name() string { return "bgscale" }
|
||||
|
||||
func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t bgScaleTheme) BackgroundImage() image.Image { return t.img }
|
||||
|
||||
func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
||||
func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
||||
func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
|
||||
|
||||
func (t bgScaleTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
||||
}
|
||||
func (t bgScaleTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t bgScaleTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
require.NotEmpty(t, d.CommandsByName("DrawImage"))
|
||||
require.Empty(t, d.CommandsByName("DrawImageScaled"))
|
||||
}
|
||||
|
||||
func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit})
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// World is 10x10 world units => 10000x10000 fixed.
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Circle near origin so that in expanded canvas (bigger than world)
|
||||
// it will appear in multiple torus tiles.
|
||||
id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000
|
||||
require.NoError(t, err)
|
||||
|
||||
w.indexObject(w.objects[id])
|
||||
|
||||
// Same geometry as points-only test:
|
||||
// viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world.
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||
|
||||
// Expect 4 circle copies, one per tile that covers the expanded canvas.
|
||||
wantNames := []string{
|
||||
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
|
||||
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
|
||||
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
|
||||
"Save", "ClipRect", "AddCircle", "Fill", "Restore",
|
||||
}
|
||||
require.Equal(t, wantNames, d.CommandNames())
|
||||
|
||||
// At zoom=1, 1 world unit -> 1 px, so:
|
||||
// circle center at (1,1) => base copy at (3,3) like point test
|
||||
// radius 1 => 1 px
|
||||
//
|
||||
// The rest are shifted by +10px in X and/or Y due to torus tiling.
|
||||
{
|
||||
clip := requireDrawerCommandAt(t, d, 1)
|
||||
require.Equal(t, "ClipRect", clip.Name)
|
||||
requireCommandArgs(t, clip, 2, 2, 10, 10)
|
||||
|
||||
c := requireDrawerCommandAt(t, d, 2)
|
||||
require.Equal(t, "AddCircle", c.Name)
|
||||
requireCommandArgs(t, c, 3, 3, 1)
|
||||
}
|
||||
|
||||
{
|
||||
clip := requireDrawerCommandAt(t, d, 6)
|
||||
require.Equal(t, "ClipRect", clip.Name)
|
||||
requireCommandArgs(t, clip, 2, 12, 10, 2)
|
||||
|
||||
c := requireDrawerCommandAt(t, d, 7)
|
||||
require.Equal(t, "AddCircle", c.Name)
|
||||
requireCommandArgs(t, c, 3, 13, 1)
|
||||
}
|
||||
|
||||
{
|
||||
clip := requireDrawerCommandAt(t, d, 11)
|
||||
require.Equal(t, "ClipRect", clip.Name)
|
||||
requireCommandArgs(t, clip, 12, 2, 2, 10)
|
||||
|
||||
c := requireDrawerCommandAt(t, d, 12)
|
||||
require.Equal(t, "AddCircle", c.Name)
|
||||
requireCommandArgs(t, c, 13, 3, 1)
|
||||
}
|
||||
|
||||
{
|
||||
clip := requireDrawerCommandAt(t, d, 16)
|
||||
require.Equal(t, "ClipRect", clip.Name)
|
||||
requireCommandArgs(t, clip, 12, 12, 2, 2)
|
||||
|
||||
c := requireDrawerCommandAt(t, d, 17)
|
||||
require.Equal(t, "AddCircle", c.Name)
|
||||
requireCommandArgs(t, c, 13, 13, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Add only a point, no circles.
|
||||
id, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
w.indexObject(w.objects[id])
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||
|
||||
// No circles => no commands.
|
||||
require.Empty(t, d.Commands())
|
||||
}
|
||||
|
||||
func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(100, 100)
|
||||
w.resetGrid(10 * SCALE)
|
||||
|
||||
// radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1.
|
||||
id, err := w.AddCircle(50, 50, 2)
|
||||
require.NoError(t, err)
|
||||
w.indexObject(w.objects[id])
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 2,
|
||||
MarginYPx: 2,
|
||||
CameraXWorldFp: 50 * SCALE,
|
||||
CameraYWorldFp: 50 * SCALE,
|
||||
CameraZoom: 2.0,
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||
|
||||
// There should be at least one AddCircle.
|
||||
cmds := d.CommandsByName("AddCircle")
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
// All circles in this plan should have radius 4px (2 units * 2x zoom).
|
||||
for _, c := range cmds {
|
||||
require.Len(t, c.Args, 3)
|
||||
require.Equal(t, 4.0, c.Args[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.SetCircleRadiusScaleFp(SCALE)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddCircle(9, 9, 2)
|
||||
require.NoError(t, err)
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
DisableWrapScroll: true,
|
||||
Layers: []RenderLayer{RenderLayerCircles},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
cmds := d.CommandsByName("AddCircle")
|
||||
require.Len(t, cmds, 1)
|
||||
|
||||
// Center must be at (9,9) only, no (-1,*) or (*,-1).
|
||||
require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
|
||||
}
|
||||
|
||||
func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
sw := 4.0
|
||||
circleStyle := w.AddStyleCircle(StyleOverride{
|
||||
FillColor: color.RGBA{A: 0}, // explicitly transparent
|
||||
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
StrokeWidthPx: &sw,
|
||||
})
|
||||
|
||||
_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
cmds := d.Commands()
|
||||
|
||||
iAdd := indexOfFirstName(cmds, "AddCircle")
|
||||
require.NotEqual(t, -1, iAdd)
|
||||
|
||||
// After AddCircle we must see Stroke (not Fill).
|
||||
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds)))
|
||||
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds)))
|
||||
|
||||
require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()")
|
||||
require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible")
|
||||
}
|
||||
|
||||
func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
sw := 2.0
|
||||
styleID := w.AddStyleCircle(StyleOverride{
|
||||
FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255},
|
||||
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
StrokeWidthPx: &sw,
|
||||
})
|
||||
|
||||
_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
cmds := d.Commands()
|
||||
iAdd := indexOfFirstName(cmds, "AddCircle")
|
||||
require.NotEqual(t, -1, iAdd)
|
||||
|
||||
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds)))
|
||||
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds)))
|
||||
|
||||
require.NotEqual(t, -1, iFill, "expected Fill() for visible fill")
|
||||
require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke")
|
||||
require.Less(t, iFill, iStroke, "Stroke must be last when both are visible")
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// World 10x10 units => 10px at zoom=1 when viewport==world.
|
||||
w := NewWorld(10, 10)
|
||||
w.SetCircleRadiusScaleFp(SCALE)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
x, y float64
|
||||
r float64
|
||||
wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0
|
||||
}
|
||||
|
||||
// Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0.
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "bottom boundary wraps to top",
|
||||
x: 5, y: 9, r: 2,
|
||||
// Centers: original at y=9, copy at y=-1.
|
||||
wantCenters: [][2]float64{{5, 9}, {5, -1}},
|
||||
},
|
||||
{
|
||||
name: "right boundary wraps to left",
|
||||
x: 9, y: 5, r: 2,
|
||||
wantCenters: [][2]float64{{9, 5}, {-1, 5}},
|
||||
},
|
||||
{
|
||||
name: "corner wraps to three extra copies",
|
||||
x: 9, y: 9, r: 2,
|
||||
wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}},
|
||||
},
|
||||
{
|
||||
name: "no wrap inside",
|
||||
x: 5, y: 5, r: 2,
|
||||
wantCenters: [][2]float64{{5, 5}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w2 := NewWorld(10, 10)
|
||||
w2.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w2.AddCircle(tt.x, tt.y, tt.r)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w2.objects {
|
||||
w2.indexObject(obj)
|
||||
}
|
||||
|
||||
plan, err := w2.buildRenderPlanStageA(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp)
|
||||
|
||||
cmds := d.CommandsByName("AddCircle")
|
||||
require.Len(t, cmds, len(tt.wantCenters))
|
||||
|
||||
// Collect centers (ignore radius for this test).
|
||||
got := make([][2]float64, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
require.Len(t, c.Args, 3)
|
||||
got = append(got, [2]float64{c.Args[0], c.Args[1]})
|
||||
}
|
||||
|
||||
// Order is deterministic with our shift generation and tile iteration for margin=0: single tile.
|
||||
require.ElementsMatch(t, tt.wantCenters, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// drawKind is used only for stable tie-breaking when priorities are equal.
|
||||
type drawKind int
|
||||
|
||||
const (
|
||||
drawKindLine drawKind = iota
|
||||
drawKindCircle
|
||||
drawKindPoint
|
||||
)
|
||||
|
||||
type drawItem struct {
|
||||
kind drawKind
|
||||
priority int
|
||||
id PrimitiveID
|
||||
styleID StyleID
|
||||
|
||||
// Exactly one of these is set.
|
||||
p Point
|
||||
c Circle
|
||||
l Line
|
||||
}
|
||||
|
||||
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
|
||||
// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism.
|
||||
//
|
||||
// allowWrap controls torus behavior:
|
||||
// - true: circles/points produce wrap copies, lines use torus-shortest segments
|
||||
// - false: no copies, lines drawn directly as stored
|
||||
// tileClipEnabled controls whether per-tile ClipRect is applied.
|
||||
// When an outer clip is already set (e.g. dirty rect), disable tile clips for speed.
|
||||
func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool, tileClipEnabled bool, isDirtyPass bool) {
|
||||
var lastStyleID StyleID = StyleIDInvalid
|
||||
var lastStyle Style
|
||||
|
||||
applyStyle := func(styleID StyleID) {
|
||||
if styleID == lastStyleID {
|
||||
return
|
||||
}
|
||||
s, ok := w.styles.Get(styleID)
|
||||
if !ok {
|
||||
panic("render: unknown style ID")
|
||||
}
|
||||
|
||||
if s.FillColor != nil {
|
||||
drawer.SetFillColor(s.FillColor)
|
||||
}
|
||||
if s.StrokeColor != nil {
|
||||
drawer.SetStrokeColor(s.StrokeColor)
|
||||
}
|
||||
drawer.SetLineWidth(s.StrokeWidthPx)
|
||||
if len(s.StrokeDashes) > 0 {
|
||||
drawer.SetDash(s.StrokeDashes...)
|
||||
} else {
|
||||
drawer.SetDash()
|
||||
}
|
||||
drawer.SetDashOffset(s.StrokeDashOffset)
|
||||
|
||||
lastStyleID = styleID
|
||||
lastStyle = s
|
||||
}
|
||||
|
||||
for _, td := range plan.Tiles {
|
||||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-tile clip is optional. When outer-clip is used (dirty rect),
|
||||
// tileClipEnabled must be false to avoid resetting the outer clip.
|
||||
if tileClipEnabled {
|
||||
drawer.Save()
|
||||
drawer.ResetClip()
|
||||
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
|
||||
}
|
||||
|
||||
items := w.scratchDrawItems[:0]
|
||||
if cap(items) < len(td.Candidates) {
|
||||
items = make([]drawItem, 0, len(td.Candidates))
|
||||
}
|
||||
|
||||
for _, it := range td.Candidates {
|
||||
id := it.ID()
|
||||
cur, ok := w.objects[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := cur.(type) {
|
||||
case Point:
|
||||
items = append(items, drawItem{
|
||||
kind: drawKindPoint,
|
||||
priority: v.Priority,
|
||||
id: v.Id,
|
||||
styleID: v.StyleID,
|
||||
p: v,
|
||||
})
|
||||
case Circle:
|
||||
items = append(items, drawItem{
|
||||
kind: drawKindCircle,
|
||||
priority: v.Priority,
|
||||
id: v.Id,
|
||||
styleID: v.StyleID,
|
||||
c: v,
|
||||
})
|
||||
case Line:
|
||||
items = append(items, drawItem{
|
||||
kind: drawKindLine,
|
||||
priority: v.Priority,
|
||||
id: v.Id,
|
||||
styleID: v.StyleID,
|
||||
l: v,
|
||||
})
|
||||
default:
|
||||
panic("render: unknown map item type")
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
if tileClipEnabled {
|
||||
drawer.Restore()
|
||||
}
|
||||
w.scratchDrawItems = items[:0]
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
a, b := items[i], items[j]
|
||||
if a.priority != b.priority {
|
||||
return a.priority < b.priority
|
||||
}
|
||||
if a.kind != b.kind {
|
||||
return a.kind < b.kind
|
||||
}
|
||||
return a.id < b.id
|
||||
})
|
||||
|
||||
// If this is not a dirty pass (full redraw), keep the old behavior for lines:
|
||||
// stroke per segment. This is usually faster for gg on huge scenes.
|
||||
if !isDirtyPass {
|
||||
for i := 0; i < len(items); i++ {
|
||||
di := items[i]
|
||||
applyStyle(di.styleID)
|
||||
|
||||
switch di.kind {
|
||||
case drawKindPoint:
|
||||
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
|
||||
case drawKindCircle:
|
||||
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
|
||||
case drawKindLine:
|
||||
// Old behavior: drawLineInTile includes Stroke() per segment.
|
||||
w.drawLineInTile(drawer, plan, td, di.l, allowWrap)
|
||||
default:
|
||||
panic("render: unknown draw kind")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dirty pass: batch lines to reduce overhead while panning.
|
||||
inLineRun := false
|
||||
var lineRunStyleID StyleID
|
||||
lineSegCount := 0
|
||||
|
||||
flushLineRun := func() {
|
||||
if !inLineRun {
|
||||
return
|
||||
}
|
||||
drawer.Stroke()
|
||||
inLineRun = false
|
||||
lineSegCount = 0
|
||||
}
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
di := items[i]
|
||||
|
||||
if inLineRun {
|
||||
if di.kind != drawKindLine || di.styleID != lineRunStyleID {
|
||||
flushLineRun()
|
||||
}
|
||||
}
|
||||
|
||||
switch di.kind {
|
||||
case drawKindLine:
|
||||
if !inLineRun {
|
||||
lineRunStyleID = di.styleID
|
||||
applyStyle(lineRunStyleID)
|
||||
inLineRun = true
|
||||
} else {
|
||||
// style matches by construction; keep style state valid if code changes later
|
||||
applyStyle(di.styleID)
|
||||
}
|
||||
|
||||
added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap)
|
||||
lineSegCount += added
|
||||
|
||||
if lineSegCount >= maxLineSegmentsPerStroke {
|
||||
drawer.Stroke()
|
||||
lineSegCount = 0
|
||||
// keep run active
|
||||
}
|
||||
|
||||
case drawKindPoint:
|
||||
flushLineRun()
|
||||
applyStyle(di.styleID)
|
||||
w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
|
||||
|
||||
case drawKindCircle:
|
||||
flushLineRun()
|
||||
applyStyle(di.styleID)
|
||||
w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
|
||||
|
||||
default:
|
||||
flushLineRun()
|
||||
panic("render: unknown draw kind")
|
||||
}
|
||||
}
|
||||
|
||||
flushLineRun()
|
||||
}
|
||||
|
||||
if tileClipEnabled {
|
||||
drawer.Restore()
|
||||
}
|
||||
|
||||
// Reuse buffer for next tile.
|
||||
w.scratchDrawItems = items[:0]
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
// Make a lot of lines, including ones that likely wrap.
|
||||
for i := 0; i < 4000; i++ {
|
||||
x1 := float64(i % 600)
|
||||
y1 := float64((i * 7) % 600)
|
||||
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
|
||||
y2 := float64((i*17 + 300) % 600)
|
||||
_, _ = w.AddLine(x1, y1, x2, y2)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
for i := 0; i < 4000; i++ {
|
||||
x1 := float64(i % 600)
|
||||
y1 := float64((i * 7) % 600)
|
||||
x2 := float64((i*13 + 500) % 600)
|
||||
y2 := float64((i*17 + 300) % 600)
|
||||
_, _ = w.AddLine(x1, y1, x2, y2)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
drawer := &fakePrimitiveDrawer{}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Reset command log so it doesn't grow forever and dominate allocations.
|
||||
drawer.Reset()
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.IndexOnViewportChange(100, 80, 1.0)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, _ = w.AddPoint(5, 5)
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
|
||||
},
|
||||
}
|
||||
|
||||
// First render initializes state.
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params))
|
||||
|
||||
// Small pan.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params2))
|
||||
|
||||
// Expect very few ClipRect calls (dirty strips count), not per tile.
|
||||
clipCmds := d2.CommandsByName("ClipRect")
|
||||
require.NotEmpty(t, clipCmds)
|
||||
require.LessOrEqual(t, len(clipCmds), 4)
|
||||
}
|
||||
|
||||
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.IndexOnViewportChange(100, 80, 1.0)
|
||||
|
||||
// Two lines with default style, same priority.
|
||||
_, _ = w.AddLine(1, 1, 8, 1)
|
||||
_, _ = w.AddLine(1, 2, 8, 2)
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
|
||||
adds := d.CommandsByName("AddLine")
|
||||
strokes := d.CommandsByName("Stroke")
|
||||
require.GreaterOrEqual(t, len(adds), 2)
|
||||
require.GreaterOrEqual(t, len(strokes), 1)
|
||||
|
||||
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
|
||||
// (Keep it loose to avoid depending on tile partitioning.)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package world
|
||||
|
||||
// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn.
|
||||
// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting.
|
||||
type lineSeg struct {
|
||||
x1, y1 int
|
||||
x2, y2 int
|
||||
}
|
||||
|
||||
// drawPointInTile draws point marker copies that intersect the tile.
|
||||
// lastStyle is already applied; it provides PointRadiusPx.
|
||||
func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) {
|
||||
rPx := lastStyle.PointRadiusPx
|
||||
if rPx <= 0 {
|
||||
// Nothing visible.
|
||||
return
|
||||
}
|
||||
|
||||
// Convert screen radius to world-fixed conservatively.
|
||||
rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp)
|
||||
|
||||
var shifts []wrapShift
|
||||
if allowWrap {
|
||||
shifts = pointWrapShifts(p, rWorldFp, w.W, w.H)
|
||||
} else {
|
||||
shifts = []wrapShift{{dx: 0, dy: 0}}
|
||||
}
|
||||
|
||||
for _, s := range shifts {
|
||||
if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
|
||||
continue
|
||||
}
|
||||
|
||||
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
|
||||
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
|
||||
|
||||
drawer.AddPoint(float64(px), float64(py), rPx)
|
||||
|
||||
fill := alphaNonZero(lastStyle.FillColor)
|
||||
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||||
|
||||
if fill {
|
||||
drawer.Fill()
|
||||
}
|
||||
if stroke {
|
||||
// Stroke must be last when both are present.
|
||||
drawer.Stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
|
||||
var shifts []wrapShift
|
||||
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
|
||||
if allowWrap {
|
||||
shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H)
|
||||
} else {
|
||||
var one [1]wrapShift
|
||||
one[0] = wrapShift{dx: 0, dy: 0}
|
||||
shifts = one[:]
|
||||
}
|
||||
|
||||
rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp)
|
||||
|
||||
for _, s := range shifts {
|
||||
if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) {
|
||||
continue
|
||||
}
|
||||
|
||||
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
|
||||
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
|
||||
|
||||
fill := alphaNonZero(lastStyle.FillColor)
|
||||
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||||
|
||||
switch {
|
||||
case fill && stroke:
|
||||
// gg consumes the current path on Fill/Stroke, so we must draw twice:
|
||||
// once for fill, then again for stroke.
|
||||
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||
drawer.Fill()
|
||||
|
||||
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||
drawer.Stroke()
|
||||
|
||||
case fill:
|
||||
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||
drawer.Fill()
|
||||
|
||||
case stroke:
|
||||
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||
drawer.Stroke()
|
||||
|
||||
default:
|
||||
// neither visible => nothing
|
||||
}
|
||||
}
|
||||
w.scratchWrapShifts = shifts[:0]
|
||||
}
|
||||
|
||||
func (w *World) drawLineInTilePath(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) int {
|
||||
segs := w.scratchLineSegs[:0]
|
||||
tmp := w.scratchLineSegsTmp[:0]
|
||||
if cap(segs) < 4 {
|
||||
segs = make([]lineSeg, 0, 4)
|
||||
}
|
||||
if cap(tmp) < 4 {
|
||||
tmp = make([]lineSeg, 0, 4)
|
||||
}
|
||||
|
||||
if allowWrap {
|
||||
segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H)
|
||||
} else {
|
||||
var one [1]lineSeg
|
||||
one[0] = lineSeg{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}
|
||||
segs = one[:]
|
||||
}
|
||||
|
||||
for _, s := range segs {
|
||||
x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
|
||||
y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
|
||||
x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
|
||||
y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
|
||||
|
||||
drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2))
|
||||
}
|
||||
|
||||
w.scratchLineSegs = segs[:0]
|
||||
w.scratchLineSegsTmp = tmp[:0]
|
||||
|
||||
return len(segs)
|
||||
}
|
||||
|
||||
func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) {
|
||||
w.drawLineInTilePath(drawer, plan, td, l, allowWrap)
|
||||
drawer.Stroke()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
|
||||
// Make grid + index available.
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
// Add enough objects so tiles have candidates.
|
||||
for i := range 2000 {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
for i := range 500 {
|
||||
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlanStageA(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// We don't clear here; we only measure the draw loop overhead.
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package world
|
||||
|
||||
func (w *World) candSeenResetIfOverflow() {
|
||||
w.candEpoch++
|
||||
if w.candEpoch != 0 {
|
||||
return
|
||||
}
|
||||
// overflow: reset stamp array
|
||||
for i := range w.candStamp {
|
||||
w.candStamp[i] = 0
|
||||
}
|
||||
w.candEpoch = 1
|
||||
}
|
||||
|
||||
func (w *World) candSeenMark(id PrimitiveID) bool {
|
||||
// ensure stamp capacity
|
||||
uid := uint32(id)
|
||||
if int(uid) >= len(w.candStamp) {
|
||||
// grow to next power-ish
|
||||
n := len(w.candStamp)
|
||||
if n == 0 {
|
||||
n = 1024
|
||||
}
|
||||
for n <= int(uid) {
|
||||
n *= 2
|
||||
}
|
||||
ns := make([]uint32, n)
|
||||
copy(ns, w.candStamp)
|
||||
w.candStamp = ns
|
||||
}
|
||||
if w.candStamp[uid] == w.candEpoch {
|
||||
return true
|
||||
}
|
||||
w.candStamp[uid] = w.candEpoch
|
||||
return false
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 4, // threshold=2
|
||||
MarginYPx: 4,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{
|
||||
AllowShiftOnly: true,
|
||||
RenderBudgetMs: 1, // 1ms budget
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// First render (full) initializes state.
|
||||
d0 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d0, params))
|
||||
require.True(t, w.renderState.initialized)
|
||||
|
||||
// Pretend previous render was very slow => over budget for the next frame.
|
||||
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
|
||||
|
||||
// Pan right by 1 unit => incremental shift candidate.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params2))
|
||||
|
||||
// Shift-only should call CopyShift but not redraw dirty rects.
|
||||
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
|
||||
require.Empty(t, d1.CommandsByName("ClipRect"))
|
||||
require.NotEmpty(t, w.renderState.pendingDirty)
|
||||
|
||||
// Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty.
|
||||
w.renderState.lastRenderDurationNs = 0 // under budget
|
||||
|
||||
params3 := params2 // same camera
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params3))
|
||||
|
||||
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
|
||||
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
|
||||
require.Empty(t, w.renderState.pendingDirty)
|
||||
}
|
||||
|
||||
func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
policy := &IncrementalPolicy{
|
||||
AllowShiftOnly: true,
|
||||
RenderBudgetMs: 1,
|
||||
}
|
||||
|
||||
base := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 4, // threshold=2
|
||||
MarginYPx: 4,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: policy,
|
||||
},
|
||||
}
|
||||
|
||||
// Initial full render.
|
||||
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
|
||||
|
||||
// Frame 1: over budget => shift-only, pendingDirty accumulates.
|
||||
w.renderState.lastRenderDurationNs = 10_000_000 // 10ms
|
||||
|
||||
p1 := base
|
||||
p1.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, p1))
|
||||
|
||||
require.NotEmpty(t, d1.CommandsByName("CopyShift"))
|
||||
require.Empty(t, d1.CommandsByName("ClipRect"))
|
||||
require.NotEmpty(t, w.renderState.pendingDirty)
|
||||
|
||||
// Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty).
|
||||
w.renderState.lastRenderDurationNs = 0
|
||||
|
||||
p2 := p1
|
||||
p2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, p2))
|
||||
|
||||
require.NotEmpty(t, d2.CommandsByName("CopyShift"))
|
||||
require.NotEmpty(t, d2.CommandsByName("ClipRect"))
|
||||
require.NotEmpty(t, d2.CommandsByName("AddPoint"))
|
||||
require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw")
|
||||
}
|
||||
|
||||
func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
policy := &IncrementalPolicy{
|
||||
AllowShiftOnly: true,
|
||||
RenderBudgetMs: 1,
|
||||
MaxCatchUpAreaPx: 20, // very small budget
|
||||
}
|
||||
|
||||
base := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 2,
|
||||
MarginXPx: 4,
|
||||
MarginYPx: 4, // canvasH = 2 + 8 = 10
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: policy,
|
||||
},
|
||||
}
|
||||
|
||||
// Full init
|
||||
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))
|
||||
|
||||
// Over budget => shift-only twice to accumulate pending dirty.
|
||||
w.renderState.lastRenderDurationNs = 10_000_000
|
||||
|
||||
p1 := base
|
||||
p1.CameraXWorldFp += 1 * SCALE
|
||||
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1))
|
||||
require.NotEmpty(t, w.renderState.pendingDirty)
|
||||
|
||||
w.renderState.lastRenderDurationNs = 10_000_000
|
||||
p2 := p1
|
||||
p2.CameraXWorldFp += 1 * SCALE
|
||||
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
|
||||
require.NotEmpty(t, w.renderState.pendingDirty)
|
||||
|
||||
// Under budget now, but limit catch-up.
|
||||
w.renderState.lastRenderDurationNs = 0
|
||||
|
||||
before := len(w.renderState.pendingDirty)
|
||||
require.Greater(t, before, 0)
|
||||
|
||||
require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
|
||||
after := len(w.renderState.pendingDirty)
|
||||
|
||||
// With a tiny MaxCatchUpAreaPx we should not clear everything in one go.
|
||||
require.Greater(t, after, 0)
|
||||
require.Less(t, after, before)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := []RectPx{
|
||||
{X: 0, Y: 0, W: 10, H: 1}, // area 10
|
||||
{X: 0, Y: 1, W: 10, H: 2}, // area 20
|
||||
{X: 0, Y: 3, W: 10, H: 3}, // area 30
|
||||
}
|
||||
|
||||
// Limit 25 => should take first (10) + second (20) would exceed => take only first.
|
||||
sel, rem := takeCatchUpRects(pending, 25)
|
||||
require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel)
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 0, Y: 1, W: 10, H: 2},
|
||||
{X: 0, Y: 3, W: 10, H: 3},
|
||||
}, rem)
|
||||
|
||||
// Limit 30 => can take first(10) + second(20) exactly.
|
||||
sel, rem = takeCatchUpRects(pending, 30)
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 0, Y: 0, W: 10, H: 1},
|
||||
{X: 0, Y: 1, W: 10, H: 2},
|
||||
}, sel)
|
||||
require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem)
|
||||
|
||||
// No limit => take all.
|
||||
sel, rem = takeCatchUpRects(pending, 0)
|
||||
require.Len(t, sel, 3)
|
||||
require.Empty(t, rem)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlanIncrementalPan_NoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalNoOp, plan.Mode)
|
||||
require.Empty(t, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0)
|
||||
require.ErrorIs(t, err, errInvalidCanvasSize)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
|
||||
plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
|
||||
plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// marginX=20 => threshold=10, dx=11 => full redraw
|
||||
plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// marginY=20 => threshold=10, dy=-11 => full redraw
|
||||
plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalFullRedraw, plan.Mode)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// marginX=40 => threshold=20, dx=5 => shift ok
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
require.Equal(t, 5, plan.DxPx)
|
||||
require.Equal(t, 0, plan.DyPx)
|
||||
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 0, Y: 0, W: 6, H: 100},
|
||||
}, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 200 - 8, Y: 0, W: 8, H: 100},
|
||||
}, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 0, Y: 0, W: 200, H: 10},
|
||||
}, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 0, Y: 100 - 10, W: 200, H: 10},
|
||||
}, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
|
||||
// Overlap is allowed; we just require both strips exist.
|
||||
require.Len(t, plan.Dirty, 2)
|
||||
require.ElementsMatch(t, []RectPx{
|
||||
{X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip
|
||||
{X: 0, Y: 0, W: 200, H: 9}, // top strip
|
||||
}, plan.Dirty)
|
||||
}
|
||||
|
||||
func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IncrementalShift, plan.Mode)
|
||||
|
||||
// Right strip width should be abs(dx)+1 = 8.
|
||||
require.Equal(t, []RectPx{
|
||||
{X: 200 - 8, Y: 0, W: 8, H: 100},
|
||||
}, plan.Dirty)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
_, err = w.AddCircle(2, 2, 1)
|
||||
require.NoError(t, err)
|
||||
_, err = w.AddLine(9, 5, 1, 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 4, // threshold=2
|
||||
MarginYPx: 4, // threshold=2
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
// First render initializes state (full redraw).
|
||||
d0 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d0, params))
|
||||
|
||||
// Pan right by 1 unit => dx=-1 => incremental shift expected.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
err = w.Render(d, params2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Must contain CopyShift for incremental path.
|
||||
require.NotEmpty(t, d.CommandsByName("CopyShift"))
|
||||
|
||||
// All clip rects should be "small": width <= 1 for dx=-1 strip.
|
||||
clipCmds := d.CommandsByName("ClipRect")
|
||||
require.NotEmpty(t, clipCmds)
|
||||
for _, c := range clipCmds {
|
||||
wPx := int(c.Args[2])
|
||||
hPx := int(c.Args[3])
|
||||
require.LessOrEqual(t, wPx, 2)
|
||||
require.LessOrEqual(t, hPx, params2.CanvasHeightPx())
|
||||
}
|
||||
|
||||
require.NotEmpty(t, d.CommandsByName("AddPoint"))
|
||||
require.NotEmpty(t, d.CommandsByName("AddCircle"))
|
||||
require.NotEmpty(t, d.CommandsByName("AddLine"))
|
||||
}
|
||||
|
||||
func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 4, // threshold=2
|
||||
MarginYPx: 4,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d0 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d0, params))
|
||||
|
||||
// Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 3 * SCALE
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
err = w.Render(d, params2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Full redraw should NOT call CopyShift.
|
||||
require.Empty(t, d.CommandsByName("CopyShift"))
|
||||
|
||||
// Full redraw should clear the entire canvas.
|
||||
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
|
||||
|
||||
// And should draw something (at least the point).
|
||||
// Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well.
|
||||
require.NotEmpty(t, d.CommandsByName("AddPoint"))
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package world
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required")
|
||||
errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required")
|
||||
errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom")
|
||||
errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size")
|
||||
)
|
||||
|
||||
// rendererIncrementalState stores the minimum state needed for incremental pan.
|
||||
type rendererIncrementalState struct {
|
||||
initialized bool
|
||||
|
||||
// Last render geometry key.
|
||||
lastZoomFp int
|
||||
|
||||
lastViewportW int
|
||||
lastViewportH int
|
||||
lastMarginX int
|
||||
lastMarginY int
|
||||
lastCanvasW int
|
||||
lastCanvasH int
|
||||
|
||||
// Last unwrapped expanded world rect used for rendering.
|
||||
lastWorldRect Rect
|
||||
|
||||
// Remainders in numerator space to make world->px conversion stable across many small pans.
|
||||
// We keep them per axis and update them during conversion.
|
||||
remXNum int64
|
||||
remYNum int64
|
||||
|
||||
// Last measured render duration (nanoseconds). Used for overload heuristics.
|
||||
lastRenderDurationNs int64
|
||||
|
||||
// Pending dirty areas accumulated during shift-only frames.
|
||||
// These are in current canvas pixel coordinates.
|
||||
pendingDirty []RectPx
|
||||
}
|
||||
|
||||
// Reset clears incremental state, forcing next frame to use full redraw.
|
||||
func (s *rendererIncrementalState) Reset() {
|
||||
*s = rendererIncrementalState{}
|
||||
}
|
||||
|
||||
// incrementalKeyFromParams extracts the geometry key that must match for incremental pan.
|
||||
func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) {
|
||||
vw = params.ViewportWidthPx
|
||||
vh = params.ViewportHeightPx
|
||||
mx = params.MarginXPx
|
||||
my = params.MarginYPx
|
||||
cw = params.CanvasWidthPx()
|
||||
ch = params.CanvasHeightPx()
|
||||
z = zoomFp
|
||||
return
|
||||
}
|
||||
|
||||
// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp,
|
||||
// carrying a signed remainder in numerator space to avoid cumulative drift.
|
||||
//
|
||||
// The conversion is:
|
||||
//
|
||||
// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE))
|
||||
//
|
||||
// and rem is updated to the exact remainder.
|
||||
//
|
||||
// This function works for negative deltas too and uses floor division semantics.
|
||||
func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int {
|
||||
if zoomFp <= 0 {
|
||||
panic("worldDeltaFixedToCanvasPx: invalid zoom")
|
||||
}
|
||||
|
||||
den := int64(SCALE) * int64(SCALE)
|
||||
num := int64(deltaWorldFp)*int64(zoomFp) + *remNum
|
||||
|
||||
q, r := floorDivRem64(num, den)
|
||||
*remNum = r
|
||||
return int(q)
|
||||
}
|
||||
|
||||
// floorDivRem64 returns (q,r) such that:
|
||||
//
|
||||
// q = floor(a / b), r = a - q*b
|
||||
//
|
||||
// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder).
|
||||
func floorDivRem64(a, b int64) (q int64, r int64) {
|
||||
if b <= 0 {
|
||||
panic("floorDivRem64: non-positive divisor")
|
||||
}
|
||||
|
||||
q = a / b
|
||||
r = a % b
|
||||
if r != 0 && a < 0 {
|
||||
q--
|
||||
r = a - q*b
|
||||
}
|
||||
return q, r
|
||||
}
|
||||
|
||||
// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image
|
||||
// when ONLY camera pan changed (no zoom/viewport/margins changes).
|
||||
//
|
||||
// Returned dxPx/dyPx are shifts to apply to the already rendered image:
|
||||
//
|
||||
// dxPx > 0 => shift image right
|
||||
// dxPx < 0 => shift image left
|
||||
//
|
||||
// This function updates internal incremental state when possible.
|
||||
// If it returns an error, the caller should fall back to a full redraw and call
|
||||
// CommitFullRedrawState afterward.
|
||||
func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) {
|
||||
zoomFp, zerr := params.CameraZoomFp()
|
||||
if zerr != nil {
|
||||
return 0, 0, zerr
|
||||
}
|
||||
if zoomFp <= 0 {
|
||||
return 0, 0, errIncrementalInvalidZoomFp
|
||||
}
|
||||
|
||||
canvasW := params.CanvasWidthPx()
|
||||
canvasH := params.CanvasHeightPx()
|
||||
if canvasW <= 0 || canvasH <= 0 {
|
||||
return 0, 0, errIncrementalInvalidCanvasPx
|
||||
}
|
||||
|
||||
newRect, rerr := params.ExpandedCanvasWorldRect()
|
||||
if rerr != nil {
|
||||
return 0, 0, rerr
|
||||
}
|
||||
|
||||
s := &w.renderState
|
||||
|
||||
// First call: no prior state => must full redraw.
|
||||
if !s.initialized {
|
||||
return 0, 0, errIncrementalStateNotReady
|
||||
}
|
||||
|
||||
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
|
||||
if s.lastZoomFp != z ||
|
||||
s.lastViewportW != vw || s.lastViewportH != vh ||
|
||||
s.lastMarginX != mx || s.lastMarginY != my ||
|
||||
s.lastCanvasW != cw || s.lastCanvasH != ch {
|
||||
return 0, 0, errIncrementalZoomMismatch
|
||||
}
|
||||
|
||||
// Compute how much the unwrapped world rect moved.
|
||||
dMinX := newRect.minX - s.lastWorldRect.minX
|
||||
dMinY := newRect.minY - s.lastWorldRect.minY
|
||||
|
||||
// Convert world movement to pixel movement of the world content.
|
||||
// If world rect moved +X (camera moved right), content appears shifted left,
|
||||
// so the old image must be shifted left: shiftPx = -deltaPx.
|
||||
deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum)
|
||||
deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum)
|
||||
|
||||
dxPx = -deltaPxX
|
||||
dyPx = -deltaPxY
|
||||
|
||||
// Update stored rect for the next incremental computation.
|
||||
s.lastWorldRect = newRect
|
||||
|
||||
return dxPx, dyPx, nil
|
||||
}
|
||||
|
||||
// CommitFullRedrawState updates incremental state after a full redraw.
|
||||
// Call this after you finish a full Render() that draws the entire expanded canvas.
|
||||
func (w *World) CommitFullRedrawState(params RenderParams) error {
|
||||
zoomFp, err := params.CameraZoomFp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if zoomFp <= 0 {
|
||||
return errIncrementalInvalidZoomFp
|
||||
}
|
||||
|
||||
rect, err := params.ExpandedCanvasWorldRect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := &w.renderState
|
||||
vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
|
||||
|
||||
s.initialized = true
|
||||
s.lastZoomFp = z
|
||||
s.lastViewportW = vw
|
||||
s.lastViewportH = vh
|
||||
s.lastMarginX = mx
|
||||
s.lastMarginY = my
|
||||
s.lastCanvasW = cw
|
||||
s.lastCanvasH = ch
|
||||
s.lastWorldRect = rect
|
||||
|
||||
// Reset remainders on a full redraw to avoid stale accumulation when geometry changes.
|
||||
s.remXNum = 0
|
||||
s.remYNum = 0
|
||||
|
||||
s.pendingDirty = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// zoom=1: px = (deltaWorldFp * 1000) / 1e6
|
||||
// For deltaWorldFp=1, each step contributes 0 px with remainder,
|
||||
// and after 1000 steps it must become 1 px total.
|
||||
zoomFp := SCALE
|
||||
var rem int64
|
||||
sum := 0
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem)
|
||||
}
|
||||
|
||||
require.Equal(t, 1, sum)
|
||||
}
|
||||
|
||||
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zoomFp := SCALE
|
||||
var rem int64
|
||||
sum := 0
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem)
|
||||
}
|
||||
|
||||
require.Equal(t, -1, sum)
|
||||
}
|
||||
|
||||
func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
_, _, err := w.ComputePanShiftPx(params)
|
||||
require.ErrorIs(t, err, errIncrementalStateNotReady)
|
||||
}
|
||||
|
||||
func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
base := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
require.NoError(t, w.CommitFullRedrawState(base))
|
||||
|
||||
changed := base
|
||||
changed.CameraZoom = 2.0
|
||||
|
||||
_, _, err := w.ComputePanShiftPx(changed)
|
||||
require.ErrorIs(t, err, errIncrementalZoomMismatch)
|
||||
|
||||
changed2 := base
|
||||
changed2.ViewportWidthPx = 101
|
||||
|
||||
_, _, err = w.ComputePanShiftPx(changed2)
|
||||
require.ErrorIs(t, err, errIncrementalZoomMismatch)
|
||||
}
|
||||
|
||||
func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
require.NoError(t, w.CommitFullRedrawState(params))
|
||||
|
||||
// Move camera right by 1 world unit => world rect minX increases by 1 unit,
|
||||
// so content moves left by 1px at zoom=1 => image shift should be -1.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
dx, dy, err := w.ComputePanShiftPx(params2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, -1, dx)
|
||||
require.Equal(t, 0, dy)
|
||||
}
|
||||
|
||||
func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
require.NoError(t, w.CommitFullRedrawState(params))
|
||||
|
||||
// Move camera up by 1 world unit => world rect minY decreases by 1 unit,
|
||||
// so content moves down by 1px => image shift should be +1 in dy.
|
||||
params2 := params
|
||||
params2.CameraYWorldFp -= 1 * SCALE
|
||||
|
||||
dx, dy, err := w.ComputePanShiftPx(params2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, dx)
|
||||
require.Equal(t, 1, dy)
|
||||
}
|
||||
|
||||
func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
require.NoError(t, w.CommitFullRedrawState(params))
|
||||
|
||||
// Pan camera right by 0.001 world units (1 fixed-point) 1000 times.
|
||||
// At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1.
|
||||
totalDx := 0
|
||||
p := params
|
||||
for i := 0; i < 1000; i++ {
|
||||
p.CameraXWorldFp += 1
|
||||
dx, dy, err := w.ComputePanShiftPx(p)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, dy)
|
||||
totalDx += dx
|
||||
}
|
||||
|
||||
require.Equal(t, -1, totalDx)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package world
|
||||
|
||||
import "image/color"
|
||||
|
||||
// RenderStyle describes visual parameters for renderer passes.
|
||||
// It is intentionally screen-space oriented (pixels), since the renderer
|
||||
// already projects world coordinates into canvas pixels.
|
||||
type RenderStyle struct {
|
||||
// PointRadiusPx is the screen-space radius for Point markers.
|
||||
PointRadiusPx float64
|
||||
|
||||
// PointFill is the fill color for points.
|
||||
PointFill color.Color
|
||||
|
||||
// CircleFill is the fill color for circles.
|
||||
CircleFill color.Color
|
||||
|
||||
// LineStroke is the stroke color for lines.
|
||||
LineStroke color.Color
|
||||
|
||||
// LineWidthPx is the stroke width for lines.
|
||||
LineWidthPx float64
|
||||
|
||||
// LineDash is the dash pattern for lines. Empty => solid.
|
||||
LineDash []float64
|
||||
|
||||
// LineDashOffset is the dash phase for lines.
|
||||
LineDashOffset float64
|
||||
}
|
||||
|
||||
// DefaultRenderStyle returns the default style used when UI does not provide one.
|
||||
// Defaults are intentionally simple and stable for testing.
|
||||
func DefaultRenderStyle() RenderStyle {
|
||||
return RenderStyle{
|
||||
PointRadiusPx: 2.0,
|
||||
PointFill: color.White,
|
||||
|
||||
CircleFill: color.White,
|
||||
|
||||
LineStroke: color.White,
|
||||
LineWidthPx: 2.0,
|
||||
LineDash: nil,
|
||||
LineDashOffset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultIncrementalPolicy() IncrementalPolicy {
|
||||
return IncrementalPolicy{
|
||||
CoalesceUpdates: false,
|
||||
AllowShiftOnly: false,
|
||||
RenderBudgetMs: 0,
|
||||
MaxCatchUpAreaPx: 0,
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Create a derived circle style so we can observe a style application transition.
|
||||
red := color.RGBA{R: 255, A: 255}
|
||||
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
|
||||
|
||||
_, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
cmds := d.Commands()
|
||||
|
||||
iSetFill := indexOfFirstName(cmds, "SetFillColor")
|
||||
iAddCircle := indexOfFirstName(cmds, "AddCircle")
|
||||
require.NotEqual(t, -1, iSetFill)
|
||||
require.NotEqual(t, -1, iAddCircle)
|
||||
|
||||
require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle")
|
||||
}
|
||||
|
||||
func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Two lines with the same default line style and same priority.
|
||||
_, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
_, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
// We expect style application at least once.
|
||||
setWidth := d.CommandsByName("SetLineWidth")
|
||||
require.NotEmpty(t, setWidth)
|
||||
|
||||
// The key batching assertion: style setters should not be called twice *between* two AddLine calls.
|
||||
cmds := d.Commands()
|
||||
line1 := indexOfFirstName(cmds, "AddLine")
|
||||
require.NotEqual(t, -1, line1)
|
||||
|
||||
line2 := indexOfNextName(cmds, "AddLine", line1+1)
|
||||
require.NotEqual(t, -1, line2)
|
||||
|
||||
// Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset,
|
||||
// because StyleID is the same and the renderer caches lastStyleID.
|
||||
for i := line1 + 1; i < line2; i++ {
|
||||
switch cmds[i].Name {
|
||||
case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor":
|
||||
t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
// Two circles, different derived fill colors => different StyleIDs.
|
||||
red := color.RGBA{R: 255, A: 255}
|
||||
green := color.RGBA{G: 255, A: 255}
|
||||
|
||||
styleRed := w.AddStyleCircle(StyleOverride{FillColor: red})
|
||||
styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green})
|
||||
|
||||
_, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
_, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, obj := range w.objects {
|
||||
w.indexObject(obj)
|
||||
}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 10,
|
||||
ViewportHeightPx: 10,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
cmds := d.Commands()
|
||||
firstCircle := indexOfFirstName(cmds, "AddCircle")
|
||||
secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1)
|
||||
require.NotEqual(t, -1, firstCircle)
|
||||
require.NotEqual(t, -1, secondCircle)
|
||||
|
||||
// There must be at least one SetFillColor before each circle.
|
||||
// And importantly, we expect a SetFillColor BETWEEN the two circles due to style change.
|
||||
setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle)
|
||||
require.NotEqual(t, -1, setBeforeFirst)
|
||||
|
||||
setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle)
|
||||
require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs")
|
||||
}
|
||||
|
||||
/* ---------- helper functions for fake command slices ---------- */
|
||||
|
||||
func indexOfFirstName(cmds []fakeDrawerCommand, name string) int {
|
||||
for i, c := range cmds {
|
||||
if c.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int {
|
||||
for i := start; i < len(cmds); i++ {
|
||||
if cmds[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int {
|
||||
if before > len(cmds) {
|
||||
before = len(cmds)
|
||||
}
|
||||
for i := before - 1; i >= 0; i-- {
|
||||
if cmds[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > len(cmds) {
|
||||
end = len(cmds)
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
if cmds[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+2633
-5
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rendererTestCase is a generic table-driven renderer test scaffold.
|
||||
// Replace invoke with the real renderer call once the renderer exists.
|
||||
type rendererTestCase struct {
|
||||
name string
|
||||
|
||||
// setup prepares the world and optional environment overrides.
|
||||
setup func(t *testing.T, env *rendererTestEnv)
|
||||
|
||||
// invoke calls the renderer under test.
|
||||
invoke func(t *testing.T, env *rendererTestEnv)
|
||||
|
||||
// verify checks the produced fake drawer log.
|
||||
verify func(t *testing.T, env *rendererTestEnv)
|
||||
}
|
||||
|
||||
func runRendererTestCases(t *testing.T, cases []rendererTestCase) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newRendererTestEnv()
|
||||
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, env)
|
||||
}
|
||||
|
||||
require.NotNil(t, tc.invoke, "renderer test case must define invoke")
|
||||
require.NotNil(t, tc.verify, "renderer test case must define verify")
|
||||
|
||||
tc.invoke(t, env)
|
||||
tc.verify(t, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderer_Template_PointCases is a scaffold for future point renderer tests.
|
||||
func TestRenderer_Template_PointCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runRendererTestCases(t, []rendererTestCase{
|
||||
{
|
||||
name: "point fully inside viewport",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
worldMustAddPoint(t, env.world, 5, 5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "point visible only in horizontal margin copy",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(160, 40)
|
||||
worldMustAddPoint(t, env.world, 0.1, 5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "point visible only in vertical margin copy",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(40, 160)
|
||||
worldMustAddPoint(t, env.world, 5, 0.1)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "point duplicated across torus corner with independent margins",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewportAndMargins(120, 60, 30, 10)
|
||||
worldMustAddPoint(t, env.world, 0.1, 0.1)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestRenderer_Template_LineCases is a scaffold for future line renderer tests.
|
||||
func TestRenderer_Template_LineCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runRendererTestCases(t, []rendererTestCase{
|
||||
{
|
||||
name: "line fully inside viewport",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
worldMustAddLine(t, env.world, 2, 2, 8, 2)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line wrap copy across x edge",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(160, 40)
|
||||
worldMustAddLine(t, env.world, 9, 5, 1, 5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line wrap copy across y edge",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(40, 160)
|
||||
worldMustAddLine(t, env.world, 5, 9, 5, 1)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line tie case uses deterministic wrapped representation",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
worldMustAddLine(t, env.world, 1, 5, 6, 5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests.
|
||||
func TestRenderer_Template_CircleCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runRendererTestCases(t, []rendererTestCase{
|
||||
{
|
||||
name: "circle fully inside viewport",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
worldMustAddCircle(t, env.world, 5, 5, 1)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "circle duplicated across horizontal edge",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(160, 40)
|
||||
worldMustAddCircle(t, env.world, 0.2, 5, 0.5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "circle duplicated across vertical edge",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewport(40, 160)
|
||||
worldMustAddCircle(t, env.world, 5, 0.2, 0.5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "circle duplicated across corner with asymmetric margins",
|
||||
setup: func(t *testing.T, env *rendererTestEnv) {
|
||||
env.setViewportAndMargins(120, 60, 30, 10)
|
||||
worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5)
|
||||
},
|
||||
invoke: func(t *testing.T, env *rendererTestEnv) {
|
||||
t.Skip("replace with actual renderer call")
|
||||
},
|
||||
verify: func(t *testing.T, env *rendererTestEnv) {
|
||||
requireNoDrawerCommands(t, env.drawer)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pointRadiusTheme struct {
|
||||
id string
|
||||
radius float64
|
||||
}
|
||||
|
||||
func (t pointRadiusTheme) ID() string { return t.id }
|
||||
func (t pointRadiusTheme) Name() string { return t.id }
|
||||
|
||||
func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t pointRadiusTheme) BackgroundImage() image.Image { return nil }
|
||||
|
||||
func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
||||
func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
||||
func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
|
||||
|
||||
func (t pointRadiusTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius}
|
||||
}
|
||||
func (t pointRadiusTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t pointRadiusTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
// Build index once.
|
||||
w.IndexOnViewportChange(100, 100, 1.0)
|
||||
|
||||
// Theme A: point radius 2
|
||||
w.SetTheme(pointRadiusTheme{id: "A", radius: 2})
|
||||
|
||||
_, err := w.AddPoint(5, 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state).
|
||||
// Render once.
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params))
|
||||
|
||||
p1 := d1.CommandsByName("AddPoint")
|
||||
require.NotEmpty(t, p1)
|
||||
r1 := p1[0].Args[2]
|
||||
require.Equal(t, 2.0, r1)
|
||||
|
||||
// Theme B: point radius 7. Change theme, but DO NOT reindex.
|
||||
w.SetTheme(pointRadiusTheme{id: "B", radius: 7})
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params))
|
||||
|
||||
p2 := d2.CommandsByName("AddPoint")
|
||||
require.NotEmpty(t, p2)
|
||||
r2 := p2[0].Args[2]
|
||||
require.Equal(t, 7.0, r2)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type cacheTheme struct{}
|
||||
|
||||
func (cacheTheme) ID() string { return "cache" }
|
||||
func (cacheTheme) Name() string { return "cache" }
|
||||
func (cacheTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (cacheTheme) BackgroundImage() image.Image { return nil }
|
||||
func (cacheTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
|
||||
func (cacheTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
|
||||
func (cacheTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
|
||||
func (cacheTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
|
||||
func (cacheTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
|
||||
func (cacheTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (cacheTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (cacheTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
|
||||
func (cacheTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
type cacheTheme2 struct{ cacheTheme }
|
||||
|
||||
func (cacheTheme2) ID() string { return "cache2" }
|
||||
func (cacheTheme2) Name() string { return "cache2" }
|
||||
func (cacheTheme2) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{B: 200, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 3}
|
||||
}
|
||||
func (cacheTheme2) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (cacheTheme2) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (cacheTheme2) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
func TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.SetTheme(cacheTheme{})
|
||||
|
||||
before := w.styles.Count()
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
ov := StyleOverride{StrokeColor: white}
|
||||
|
||||
id1, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
|
||||
require.NoError(t, err)
|
||||
id2, err := w.AddCircle(6, 5, 2, CircleWithStyleOverride(ov))
|
||||
require.NoError(t, err)
|
||||
|
||||
c1 := w.objects[id1].(Circle)
|
||||
c2 := w.objects[id2].(Circle)
|
||||
require.Equal(t, c1.StyleID, c2.StyleID, "same override must reuse derived style ID")
|
||||
|
||||
afterAdd := w.styles.Count()
|
||||
require.Equal(t, before+1, afterAdd, "only one derived style should be added for identical overrides")
|
||||
|
||||
// Change theme: derived cache is cleared and new base IDs are created; override must still be applied,
|
||||
// and both objects should again share one derived style for the new base.
|
||||
w.SetTheme(cacheTheme2{})
|
||||
|
||||
c1b := w.objects[id1].(Circle)
|
||||
c2b := w.objects[id2].(Circle)
|
||||
require.Equal(t, c1b.StyleID, c2b.StyleID)
|
||||
|
||||
afterTheme := w.styles.Count()
|
||||
// Theme change creates 3 new theme default styles + 1 new derived for the override.
|
||||
require.GreaterOrEqual(t, afterTheme, afterAdd+4)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"hash"
|
||||
"hash/fnv"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// hashU64 writes v to the hash in little-endian form.
|
||||
// We keep it manual to avoid extra allocations and dependencies.
|
||||
func hashU64(h hash.Hash64, v uint64) {
|
||||
var b [8]byte
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
b[4] = byte(v >> 32)
|
||||
b[5] = byte(v >> 40)
|
||||
b[6] = byte(v >> 48)
|
||||
b[7] = byte(v >> 56)
|
||||
_, _ = h.Write(b[:])
|
||||
}
|
||||
|
||||
func hashBool(h hash.Hash64, v bool) {
|
||||
if v {
|
||||
hashU64(h, 1)
|
||||
} else {
|
||||
hashU64(h, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func hashColor(h hash.Hash64, c color.Color) {
|
||||
if c == nil {
|
||||
hashU64(h, 0)
|
||||
return
|
||||
}
|
||||
r, g, b, a := c.RGBA()
|
||||
hashU64(h, uint64(r))
|
||||
hashU64(h, uint64(g))
|
||||
hashU64(h, uint64(b))
|
||||
hashU64(h, uint64(a))
|
||||
}
|
||||
|
||||
// fingerprint returns a stable hash of the override content.
|
||||
//
|
||||
// Notes on semantics:
|
||||
// - FillColor / StrokeColor: nil means "unset" (do not override). Transparent override is represented
|
||||
// by a non-nil color with alpha=0.
|
||||
// - Pointer fields (*float64, *[]float64) encode presence via nil/non-nil.
|
||||
// - StrokeDashes: nil pointer means "unset"; non-nil pointer to nil slice means "set to nil".
|
||||
func (o StyleOverride) fingerprint() uint64 {
|
||||
h := fnv.New64a() // returns hash.Hash64
|
||||
|
||||
// FillColor / StrokeColor
|
||||
hashBool(h, o.FillColor != nil)
|
||||
hashColor(h, o.FillColor)
|
||||
|
||||
hashBool(h, o.StrokeColor != nil)
|
||||
hashColor(h, o.StrokeColor)
|
||||
|
||||
// StrokeWidthPx
|
||||
hashBool(h, o.StrokeWidthPx != nil)
|
||||
if o.StrokeWidthPx != nil {
|
||||
hashU64(h, math.Float64bits(*o.StrokeWidthPx))
|
||||
}
|
||||
|
||||
// StrokeDashes
|
||||
hashBool(h, o.StrokeDashes != nil)
|
||||
if o.StrokeDashes != nil {
|
||||
ds := *o.StrokeDashes
|
||||
if ds == nil {
|
||||
// Explicitly set to nil slice
|
||||
hashU64(h, 0xffffffffffffffff)
|
||||
} else {
|
||||
hashU64(h, uint64(len(ds)))
|
||||
for _, v := range ds {
|
||||
hashU64(h, math.Float64bits(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StrokeDashOffset
|
||||
hashBool(h, o.StrokeDashOffset != nil)
|
||||
if o.StrokeDashOffset != nil {
|
||||
hashU64(h, math.Float64bits(*o.StrokeDashOffset))
|
||||
}
|
||||
|
||||
// PointRadiusPx
|
||||
hashBool(h, o.PointRadiusPx != nil)
|
||||
if o.PointRadiusPx != nil {
|
||||
hashU64(h, math.Float64bits(*o.PointRadiusPx))
|
||||
}
|
||||
|
||||
return h.Sum64()
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package world
|
||||
|
||||
func mergeOverrides(classOv, userOv StyleOverride) StyleOverride {
|
||||
out := classOv
|
||||
|
||||
// Colors: nil means "unset"
|
||||
if userOv.FillColor != nil {
|
||||
out.FillColor = userOv.FillColor
|
||||
}
|
||||
if userOv.StrokeColor != nil {
|
||||
out.StrokeColor = userOv.StrokeColor
|
||||
}
|
||||
|
||||
// Pointers: nil means "unset"
|
||||
if userOv.StrokeWidthPx != nil {
|
||||
out.StrokeWidthPx = userOv.StrokeWidthPx
|
||||
}
|
||||
if userOv.StrokeDashes != nil {
|
||||
out.StrokeDashes = userOv.StrokeDashes
|
||||
}
|
||||
if userOv.StrokeDashOffset != nil {
|
||||
out.StrokeDashOffset = userOv.StrokeDashOffset
|
||||
}
|
||||
if userOv.PointRadiusPx != nil {
|
||||
out.PointRadiusPx = userOv.PointRadiusPx
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
+476
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+606
-2
@@ -1,12 +1,12 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestWrap verifies wrap.
|
||||
func TestWrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestWrap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClamp verifies clamp.
|
||||
func TestClamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -65,6 +66,7 @@ func TestClamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCeilDiv verifies ceil Div.
|
||||
func TestCeilDiv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -89,6 +91,7 @@ func TestCeilDiv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFloorDiv verifies floor Div.
|
||||
func TestFloorDiv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -107,6 +110,7 @@ func TestFloorDiv(t *testing.T) {
|
||||
require.Panics(t, func() { _ = floorDiv(1, -1) })
|
||||
}
|
||||
|
||||
// TestFixedPoint verifies fixed Point.
|
||||
func TestFixedPoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -135,6 +139,7 @@ func TestFixedPoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbs verifies abs.
|
||||
func TestAbs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -154,6 +159,7 @@ func TestAbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPixelSpanToWorldFixed verifies pixel Span To World Fixed.
|
||||
func TestPixelSpanToWorldFixed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -181,6 +187,7 @@ func TestPixelSpanToWorldFixed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldToCellPanicsOnInvalidGrid verifies world To Cell Panics On Invalid Grid.
|
||||
func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -211,6 +218,7 @@ func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortestWrappedDelta verifies shortest Wrapped Delta.
|
||||
func TestShortestWrappedDelta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -252,6 +260,7 @@ func TestShortestWrappedDelta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCameraZoomToWorldFixed verifies camera Zoom To World Fixed.
|
||||
func TestCameraZoomToWorldFixed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -309,6 +318,7 @@ func TestCameraZoomToWorldFixed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCameraZoomToWorldFixedReturnsError verifies camera Zoom To World Fixed Returns Error.
|
||||
func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -354,6 +364,7 @@ func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustCameraZoomToWorldFixed verifies must Camera Zoom To World Fixed.
|
||||
func TestMustCameraZoomToWorldFixed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -363,3 +374,596 @@ func TestMustCameraZoomToWorldFixed(t *testing.T) {
|
||||
_ = mustCameraZoomToWorldFixed(0)
|
||||
})
|
||||
}
|
||||
|
||||
// TestWorldFixedToCameraZoom verifies world Fixed To Camera Zoom.
|
||||
func TestWorldFixedToCameraZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
zoomFp int
|
||||
want float64
|
||||
}{
|
||||
{name: "zero", zoomFp: 0, want: 0},
|
||||
{name: "neutral", zoomFp: SCALE, want: 1.0},
|
||||
{name: "fractional", zoomFp: 1250, want: 1.25},
|
||||
{name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := worldFixedToCameraZoom(tt.zoomFp)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequiredZoomToFitWorld verifies required Zoom To Fit World.
|
||||
func TestRequiredZoomToFitWorld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
viewportSpanPx int
|
||||
worldSpanFp int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "zero viewport span",
|
||||
viewportSpanPx: 0,
|
||||
worldSpanFp: 10 * SCALE,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "exact neutral fit",
|
||||
viewportSpanPx: 10,
|
||||
worldSpanFp: 10 * SCALE,
|
||||
want: SCALE,
|
||||
},
|
||||
{
|
||||
name: "exact 2x fit",
|
||||
viewportSpanPx: 20,
|
||||
worldSpanFp: 10 * SCALE,
|
||||
want: 2 * SCALE,
|
||||
},
|
||||
{
|
||||
name: "fractional fit rounded up",
|
||||
viewportSpanPx: 11,
|
||||
worldSpanFp: 10 * SCALE,
|
||||
want: 1100,
|
||||
},
|
||||
{
|
||||
name: "small world requires larger zoom",
|
||||
viewportSpanPx: 320,
|
||||
worldSpanFp: 80 * SCALE,
|
||||
want: 4 * SCALE,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequiredZoomToFitWorldPanics verifies required Zoom To Fit World Panics.
|
||||
func TestRequiredZoomToFitWorldPanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
viewportSpanPx int
|
||||
worldSpanFp int
|
||||
}{
|
||||
{
|
||||
name: "negative viewport span",
|
||||
viewportSpanPx: -1,
|
||||
worldSpanFp: 10 * SCALE,
|
||||
},
|
||||
{
|
||||
name: "zero world span",
|
||||
viewportSpanPx: 10,
|
||||
worldSpanFp: 0,
|
||||
},
|
||||
{
|
||||
name: "negative world span",
|
||||
viewportSpanPx: 10,
|
||||
worldSpanFp: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, func() {
|
||||
_ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded verifies correct Camera Zoom Fp Returns Current When No Correction Needed.
|
||||
func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
2*SCALE,
|
||||
40, 30,
|
||||
100*SCALE, 100*SCALE,
|
||||
MIN_ZOOM, MAX_ZOOM,
|
||||
)
|
||||
|
||||
require.Equal(t, 2*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth verifies correct Camera Zoom Fp Raises Zoom To Fit World Width.
|
||||
func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
120, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1200, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight verifies correct Camera Zoom Fp Raises Zoom To Fit World Height.
|
||||
func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
20, 150,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpUsesMaxFitAcrossAxes verifies correct Camera Zoom Fp Uses Max Fit Across Axes.
|
||||
func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
120, 150,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit verifies correct Camera Zoom Fp Applies Min Zoom When Larger Than Current And Fit.
|
||||
func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
1500, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Fit Conflict.
|
||||
func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
4*SCALE,
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 3*SCALE,
|
||||
)
|
||||
|
||||
require.Equal(t, 3*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore verifies correct Camera Zoom Fp Ignores Max Zoom When Fit Needs More.
|
||||
func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
200, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 1500,
|
||||
)
|
||||
|
||||
require.Equal(t, 2*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid verifies correct Camera Zoom Fp Applies Min Then Max When Both Valid.
|
||||
func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
1500, 1600,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped verifies correct Camera Zoom Fp Current Above Max Gets Clamped.
|
||||
func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
5*SCALE,
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 3*SCALE,
|
||||
)
|
||||
|
||||
require.Equal(t, 3*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
|
||||
func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
0, 0,
|
||||
100*SCALE, 100*SCALE,
|
||||
1500, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpZeroBoundsAreIgnored verifies correct Camera Zoom Fp Zero Bounds Are Ignored.
|
||||
func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
1250,
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1250, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFpPanics verifies correct Camera Zoom Fp Panics.
|
||||
func TestCorrectCameraZoomFpPanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fn func()
|
||||
}{
|
||||
{
|
||||
name: "non-positive current zoom",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative viewport width",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative viewport height",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-positive world width",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-positive world height",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative min zoom",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative max zoom",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "min greater than max",
|
||||
fn: func() {
|
||||
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, tt.fn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldCorrectCameraZoomReturnsFloatValue verifies world Correct Camera Zoom Returns Float Value.
|
||||
func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(100, 100)
|
||||
|
||||
got := w.CorrectCameraZoom(1.0, 120, 20)
|
||||
|
||||
require.Equal(t, 1.2, got)
|
||||
}
|
||||
|
||||
// TestWorldCorrectCameraZoomAppliesDefaultBounds verifies world Correct Camera Zoom Applies Default Bounds.
|
||||
func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(100, 100)
|
||||
|
||||
got := w.CorrectCameraZoom(100.0, 20, 20)
|
||||
|
||||
require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got)
|
||||
}
|
||||
|
||||
// TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound verifies world Correct Camera Zoom Fit Beats Default Max Bound.
|
||||
func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(1, 100)
|
||||
|
||||
got := w.CorrectCameraZoom(1.0, 40, 10)
|
||||
|
||||
require.Equal(t, 40.0, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld verifies correct Camera Zoom Fp Does Not Lower Zoom When Viewport Is Smaller Than World.
|
||||
func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE, // currentZoomFp = 1.0x
|
||||
80, 80, // viewport px
|
||||
100*SCALE, 100*SCALE, // world fp
|
||||
0, 0,
|
||||
)
|
||||
|
||||
// No anti-wrap needed, and we do not auto-fit by lowering zoom.
|
||||
require.Equal(t, SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger verifies correct Camera Zoom Fp Raises Zoom To Prevent Wrap When Viewport Is Larger.
|
||||
func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large.
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
120, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1200, got) // 1.2x
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Wrap Conflict.
|
||||
func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
4*SCALE, // user wants 4x
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 3*SCALE, // max 3x
|
||||
)
|
||||
|
||||
require.Equal(t, 3*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom verifies correct Camera Zoom Fp Anti Wrap Beats Max Zoom.
|
||||
func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// requiredFit = 2x, but max is 1.5x => must return 2x.
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
200, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
0, 1500,
|
||||
)
|
||||
|
||||
require.Equal(t, 2*SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_AppliesMinZoom verifies correct Camera Zoom Fp Applies Min Zoom.
|
||||
func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
800, // 0.8x
|
||||
20, 20,
|
||||
100*SCALE, 100*SCALE,
|
||||
SCALE, 0, // min 1.0x
|
||||
)
|
||||
|
||||
require.Equal(t, SCALE, got)
|
||||
}
|
||||
|
||||
// TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
|
||||
func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := correctCameraZoomFp(
|
||||
SCALE,
|
||||
0, 0,
|
||||
100*SCALE, 100*SCALE,
|
||||
1500, 0,
|
||||
)
|
||||
|
||||
require.Equal(t, 1500, got)
|
||||
}
|
||||
|
||||
// TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld verifies clamp Camera No Wrap Viewport Clamps To Keep Viewport Inside World.
|
||||
func TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
worldW := 100 * SCALE
|
||||
worldH := 100 * SCALE
|
||||
zoomFp := SCALE // 1.0x
|
||||
|
||||
// viewport 40px => span 40 units => half 20.
|
||||
viewportW, viewportH := 40, 40
|
||||
|
||||
// Too far left/up => clamp to minCam=20
|
||||
cx, cy := ClampCameraNoWrapViewport(
|
||||
0, 0,
|
||||
viewportW, viewportH,
|
||||
zoomFp,
|
||||
worldW, worldH,
|
||||
)
|
||||
require.Equal(t, 20*SCALE, cx)
|
||||
require.Equal(t, 20*SCALE, cy)
|
||||
|
||||
// Too far right/down => clamp to maxCam=world-half=80
|
||||
cx, cy = ClampCameraNoWrapViewport(
|
||||
99*SCALE, 99*SCALE,
|
||||
viewportW, viewportH,
|
||||
zoomFp,
|
||||
worldW, worldH,
|
||||
)
|
||||
require.Equal(t, 80*SCALE, cx)
|
||||
require.Equal(t, 80*SCALE, cy)
|
||||
|
||||
// Inside range => unchanged
|
||||
cx, cy = ClampCameraNoWrapViewport(
|
||||
50*SCALE, 60*SCALE,
|
||||
viewportW, viewportH,
|
||||
zoomFp,
|
||||
worldW, worldH,
|
||||
)
|
||||
require.Equal(t, 50*SCALE, cx)
|
||||
require.Equal(t, 60*SCALE, cy)
|
||||
}
|
||||
|
||||
// TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter verifies clamp Camera No Wrap Viewport When Viewport Larger Than World Forces Center.
|
||||
func TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
worldW := 50 * SCALE
|
||||
worldH := 50 * SCALE
|
||||
zoomFp := SCALE
|
||||
|
||||
// viewport 60px => span 60 units > world 50
|
||||
viewportW, viewportH := 60, 60
|
||||
|
||||
cx, cy := ClampCameraNoWrapViewport(
|
||||
0, 0,
|
||||
viewportW, viewportH,
|
||||
zoomFp,
|
||||
worldW, worldH,
|
||||
)
|
||||
|
||||
require.Equal(t, worldW/2, cx)
|
||||
require.Equal(t, worldH/2, cy)
|
||||
}
|
||||
|
||||
// TestWorldClampRenderParamsNoWrap_UsesViewportClamp verifies world Clamp Render Params No Wrap Uses Viewport Clamp.
|
||||
func TestWorldClampRenderParamsNoWrap_UsesViewportClamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(100, 100)
|
||||
|
||||
p := RenderParams{
|
||||
ViewportWidthPx: 40,
|
||||
ViewportHeightPx: 40,
|
||||
MarginXPx: 10,
|
||||
MarginYPx: 10,
|
||||
CameraZoom: 1.0,
|
||||
CameraXWorldFp: 0,
|
||||
CameraYWorldFp: 0,
|
||||
Options: &RenderOptions{DisableWrapScroll: true},
|
||||
}
|
||||
|
||||
w.ClampRenderParamsNoWrap(&p)
|
||||
|
||||
// viewport half is 20, not 30 (margins ignored)
|
||||
require.Equal(t, 20*SCALE, p.CameraXWorldFp)
|
||||
require.Equal(t, 20*SCALE, p.CameraYWorldFp)
|
||||
}
|
||||
|
||||
// TestPivotZoom_CursorAtCenter_KeepsCamera verifies pivot Zoom Cursor At Center Keeps Camera.
|
||||
func TestPivotZoom_CursorAtCenter_KeepsCamera(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cx, cy := PivotZoomCameraNoWrap(
|
||||
50*SCALE, 60*SCALE,
|
||||
100, 80,
|
||||
50, 40, // cursor at center
|
||||
SCALE, 2*SCALE,
|
||||
)
|
||||
require.Equal(t, 50*SCALE, cx)
|
||||
require.Equal(t, 60*SCALE, cy)
|
||||
}
|
||||
|
||||
// TestPivotZoom_RightEdge_ZoomInMovesCameraRight verifies pivot Zoom Right Edge Zoom In Moves Camera Right.
|
||||
func TestPivotZoom_RightEdge_ZoomInMovesCameraRight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// viewport 100px, cursor at x=100 (right edge), center is 50 => offX=50px
|
||||
// zoom 1->2 halves worldPerPx, so camera must move towards the cursor to keep the same world point.
|
||||
cx0 := 50 * SCALE
|
||||
|
||||
cx, _ := PivotZoomCameraNoWrap(
|
||||
cx0, 50*SCALE,
|
||||
100, 100,
|
||||
100, 50,
|
||||
SCALE, 2*SCALE,
|
||||
)
|
||||
require.Greater(t, cx, cx0)
|
||||
}
|
||||
|
||||
// TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft verifies pivot Zoom Left Edge Zoom In Moves Camera Left.
|
||||
func TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cx0 := 50 * SCALE
|
||||
|
||||
cx, _ := PivotZoomCameraNoWrap(
|
||||
cx0, 50*SCALE,
|
||||
100, 100,
|
||||
0, 50,
|
||||
SCALE, 2*SCALE,
|
||||
)
|
||||
require.Less(t, cx, cx0)
|
||||
}
|
||||
|
||||
+360
-7
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddPoint_DefaultsPriorityAndStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
id, err := w.AddPoint(1, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Point)
|
||||
require.Equal(t, DefaultPriorityPoint, obj.Priority)
|
||||
require.Equal(t, StyleIDDefaultPoint, obj.StyleID)
|
||||
}
|
||||
|
||||
func TestAddCircle_DefaultsPriorityAndStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
id, err := w.AddCircle(1, 1, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Circle)
|
||||
require.Equal(t, DefaultPriorityCircle, obj.Priority)
|
||||
require.Equal(t, StyleIDDefaultCircle, obj.StyleID)
|
||||
}
|
||||
|
||||
func TestAddLine_DefaultsPriorityAndStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
id, err := w.AddLine(1, 1, 2, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Line)
|
||||
require.Equal(t, DefaultPriorityLine, obj.Priority)
|
||||
require.Equal(t, StyleIDDefaultLine, obj.StyleID)
|
||||
}
|
||||
|
||||
func TestAddStyleLine_ThenUseStyleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
width := 5.0
|
||||
ov := StyleOverride{StrokeWidthPx: &width}
|
||||
styleID := w.AddStyleLine(ov)
|
||||
|
||||
id, err := w.AddLine(1, 1, 2, 2, LineWithStyleID(styleID), LineWithPriority(777))
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Line)
|
||||
require.Equal(t, 777, obj.Priority)
|
||||
require.Equal(t, styleID, obj.StyleID)
|
||||
|
||||
s, ok := w.styles.Get(styleID)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 5.0, s.StrokeWidthPx)
|
||||
}
|
||||
|
||||
func TestAddPoint_WithOverride_CreatesDerivedStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
newRadius := 9.0
|
||||
ov := StyleOverride{PointRadiusPx: &newRadius}
|
||||
|
||||
id, err := w.AddPoint(1, 1, PointWithStyleOverride(ov))
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Point)
|
||||
require.NotEqual(t, StyleIDDefaultPoint, obj.StyleID)
|
||||
|
||||
s, ok := w.styles.Get(obj.StyleID)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 9.0, s.PointRadiusPx)
|
||||
}
|
||||
|
||||
func TestExplicitStyleID_WinsOverOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
red := color.RGBA{R: 255, A: 255}
|
||||
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
|
||||
|
||||
// Try to override radius in options too; StyleID must win, override must be ignored.
|
||||
width := 123.0
|
||||
id, err := w.AddCircle(2, 2, 1,
|
||||
CircleWithStyleID(styleID),
|
||||
CircleWithStyleOverride(StyleOverride{StrokeWidthPx: &width}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := w.objects[id].(Circle)
|
||||
require.Equal(t, styleID, obj.StyleID)
|
||||
|
||||
s, ok := w.styles.Get(styleID)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, red, s.FillColor)
|
||||
// width override must not affect styleID.
|
||||
require.NotEqual(t, 123.0, s.StrokeWidthPx)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user