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("Starting UI client v%s", target.Version))
|
||||||
l.logText(fmt.Sprintf("Executable: %s", target.Path))
|
l.logText(fmt.Sprintf("Executable: %s", target.Path))
|
||||||
|
|
||||||
fyne.Do(func() {
|
|
||||||
l.debugWindow.Hide()
|
|
||||||
})
|
|
||||||
|
|
||||||
exitCode, runErr := l.runner.Run(ctx, target.Path)
|
exitCode, runErr := l.runner.Run(ctx, target.Path)
|
||||||
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
|
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
|
||||||
|
|
||||||
@@ -124,7 +120,7 @@ func (l *loader) init(ctx context.Context) {
|
|||||||
l.btn.Hide()
|
l.btn.Hide()
|
||||||
l.btn.Disable()
|
l.btn.Disable()
|
||||||
// show debugWindow can be done with future debug mode, e.g. with -debug flag
|
// show debugWindow can be done with future debug mode, e.g. with -debug flag
|
||||||
// l.window.Show()
|
l.debugWindow.Hide()
|
||||||
})
|
})
|
||||||
|
|
||||||
err := l.runOnce(ctx)
|
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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/fogleman/gg"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"github.com/fogleman/gg"
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
|
// 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) {
|
func rgba8(c color.Color) (R, G, B, A byte) {
|
||||||
r, g, b, a := c.RGBA()
|
r, g, b, a := c.RGBA()
|
||||||
return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)
|
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.DrawImage(img, 0, 0)
|
||||||
g.DC.Pop()
|
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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"fmt"
|
||||||
"image/color"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/fogleman/gg"
|
"github.com/fogleman/gg"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func hasAnyNonTransparentPixel(img image.Image) bool {
|
func hasAnyNonTransparentPixel(img image.Image) bool {
|
||||||
@@ -27,6 +28,7 @@ func pixelHasAlpha(img image.Image, x, y int) bool {
|
|||||||
return a != 0
|
return a != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerStrokeSequenceProducesPixels verifies gG Drawer Stroke Sequence Produces Pixels.
|
||||||
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
|
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
|
|||||||
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
|
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerFillSequenceProducesPixels verifies gG Drawer Fill Sequence Produces Pixels.
|
||||||
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
|
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
|
|||||||
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerPointSequenceProducesPixels verifies gG Drawer Point Sequence Produces Pixels.
|
||||||
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
|
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
|
|||||||
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerClipRectLimitsDrawing verifies gG Drawer Clip Rect Limits Drawing.
|
||||||
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
|
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -88,6 +93,7 @@ func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
|
|||||||
require.False(t, pixelHasAlpha(img, 15, 16))
|
require.False(t, pixelHasAlpha(img, 15, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerResetClipClearsClip verifies gG Drawer Reset Clip Clears Clip.
|
||||||
func TestGGDrawerResetClipClearsClip(t *testing.T) {
|
func TestGGDrawerResetClipClearsClip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -103,6 +109,7 @@ func TestGGDrawerResetClipClearsClip(t *testing.T) {
|
|||||||
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerClearRectTo_FillsBackground verifies gG Drawer Clear Rect To Fills Background.
|
||||||
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
|
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -130,6 +137,7 @@ func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
|
|||||||
require.NotEqual(t, uint32(0), a2)
|
require.NotEqual(t, uint32(0), a2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerSaveRestoreRestoresClipState verifies gG Drawer Save Restore Restores Clip State.
|
||||||
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
|
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -147,6 +155,7 @@ func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
|
|||||||
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerNestedSaveRestoreRestoresOuterClip verifies gG Drawer Nested Save Restore Restores Outer Clip.
|
||||||
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
|
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -169,6 +178,7 @@ func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
|
|||||||
require.False(t, pixelHasAlpha(img, 25, 16))
|
require.False(t, pixelHasAlpha(img, 25, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFakePrimitiveDrawerRecordsCommandsAndState verifies fake Primitive Drawer Records Commands And State.
|
||||||
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
|
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
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) {
|
func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -236,6 +248,7 @@ func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
|
|||||||
require.Equal(t, 0, d.SaveDepth())
|
require.Equal(t, 0, d.SaveDepth())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFakePrimitiveDrawerResetClipClearsOnlyClipState verifies fake Primitive Drawer Reset Clip Clears Only Clip State.
|
||||||
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
|
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -251,6 +264,7 @@ func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
|
|||||||
require.Empty(t, state.Clips)
|
require.Empty(t, state.Clips)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGGDrawerCopyShift_ShiftsPixels verifies gG Drawer Copy Shift Shifts Pixels.
|
||||||
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
|
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -276,3 +290,372 @@ func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
|
|||||||
_, _, _, a2 := img.At(0, 0).RGBA()
|
_, _, _, a2 := img.At(0, 0).RGBA()
|
||||||
require.Equal(t, uint32(0), a2)
|
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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"image/color"
|
"image/color"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
|
||||||
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
|
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
|
|||||||
require.Equal(t, idLine, hits[2].ID)
|
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) {
|
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
|
|||||||
require.Equal(t, idCircle, hits[0].ID)
|
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) {
|
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
|
|||||||
require.NotEmpty(t, hits)
|
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) {
|
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -151,6 +154,7 @@ func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
|
|||||||
require.Equal(t, KindCircle, hits[0].Kind)
|
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) {
|
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
|
||||||
t.Parallel()
|
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.
|
// 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.
|
// 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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,10 @@ var (
|
|||||||
transparentColor color.Color = &color.RGBA{A: 0}
|
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 }
|
func TransparentFill() color.Color { return transparentColor }
|
||||||
|
|
||||||
// Style is a fully resolved style used by the renderer.
|
// Style is a fully resolved style used by the renderer.
|
||||||
@@ -232,3 +237,461 @@ func (t *StyleTable) Count() int {
|
|||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
return len(t.styles)
|
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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestStyleOverrideApply_OverridesOnlyProvidedFields verifies style Override Apply Overrides Only Provided Fields.
|
||||||
func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
|
func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
|
|||||||
require.Equal(t, 7.0, out.PointRadiusPx)
|
require.Equal(t, 7.0, out.PointRadiusPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStyleTable_DefaultsExistAndAreStable verifies style Table Defaults Exist And Are Stable.
|
||||||
func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
|
func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
|
|||||||
require.True(t, ok)
|
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) {
|
func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T)
|
|||||||
require.Equal(t, []float64{10, 5}, got3.StrokeDashes)
|
require.Equal(t, []float64{10, 5}, got3.StrokeDashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultPriorities_AreOrderedAndStepped verifies default Priorities Are Ordered And Stepped.
|
||||||
func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
|
func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -93,3 +97,473 @@ func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
|
|||||||
require.Less(t, DefaultPriorityLine, DefaultPriorityCircle)
|
require.Less(t, DefaultPriorityLine, DefaultPriorityCircle)
|
||||||
require.Less(t, DefaultPriorityCircle, DefaultPriorityPoint)
|
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
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestWrap verifies wrap.
|
||||||
func TestWrap(t *testing.T) {
|
func TestWrap(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ func TestWrap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClamp verifies clamp.
|
||||||
func TestClamp(t *testing.T) {
|
func TestClamp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ func TestClamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCeilDiv verifies ceil Div.
|
||||||
func TestCeilDiv(t *testing.T) {
|
func TestCeilDiv(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ func TestCeilDiv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFloorDiv verifies floor Div.
|
||||||
func TestFloorDiv(t *testing.T) {
|
func TestFloorDiv(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -107,6 +110,7 @@ func TestFloorDiv(t *testing.T) {
|
|||||||
require.Panics(t, func() { _ = floorDiv(1, -1) })
|
require.Panics(t, func() { _ = floorDiv(1, -1) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFixedPoint verifies fixed Point.
|
||||||
func TestFixedPoint(t *testing.T) {
|
func TestFixedPoint(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ func TestFixedPoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAbs verifies abs.
|
||||||
func TestAbs(t *testing.T) {
|
func TestAbs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -154,6 +159,7 @@ func TestAbs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPixelSpanToWorldFixed verifies pixel Span To World Fixed.
|
||||||
func TestPixelSpanToWorldFixed(t *testing.T) {
|
func TestPixelSpanToWorldFixed(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -211,6 +218,7 @@ func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestShortestWrappedDelta verifies shortest Wrapped Delta.
|
||||||
func TestShortestWrappedDelta(t *testing.T) {
|
func TestShortestWrappedDelta(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -252,6 +260,7 @@ func TestShortestWrappedDelta(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCameraZoomToWorldFixed verifies camera Zoom To World Fixed.
|
||||||
func TestCameraZoomToWorldFixed(t *testing.T) {
|
func TestCameraZoomToWorldFixed(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -354,6 +364,7 @@ func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMustCameraZoomToWorldFixed verifies must Camera Zoom To World Fixed.
|
||||||
func TestMustCameraZoomToWorldFixed(t *testing.T) {
|
func TestMustCameraZoomToWorldFixed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -363,3 +374,596 @@ func TestMustCameraZoomToWorldFixed(t *testing.T) {
|
|||||||
_ = mustCameraZoomToWorldFixed(0)
|
_ = 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")
|
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 {
|
type indexState struct {
|
||||||
initialized bool
|
initialized bool
|
||||||
viewportW int
|
viewportW int
|
||||||
viewportH int
|
viewportH int
|
||||||
zoomFp int
|
zoomFp int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// derivedStyleKey identifies one cached derived style by its base style and
|
||||||
|
// stable override fingerprint.
|
||||||
type derivedStyleKey struct {
|
type derivedStyleKey struct {
|
||||||
base StyleID
|
base StyleID
|
||||||
fp uint64
|
fp uint64
|
||||||
@@ -163,7 +168,7 @@ func (w *World) SetTheme(theme StyleTheme) {
|
|||||||
w.themeDefaultCircleStyleID = w.styles.AddStyle(theme.CircleStyle())
|
w.themeDefaultCircleStyleID = w.styles.AddStyle(theme.CircleStyle())
|
||||||
w.themeDefaultPointStyleID = w.styles.AddStyle(theme.PointStyle())
|
w.themeDefaultPointStyleID = w.styles.AddStyle(theme.PointStyle())
|
||||||
|
|
||||||
w.reresolveThemeManagedStyles()
|
w.refreshThemeManagedStyles()
|
||||||
|
|
||||||
// Full redraw to apply new background and base styles.
|
// Full redraw to apply new background and base styles.
|
||||||
w.renderState.Reset()
|
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()
|
th := w.Theme()
|
||||||
|
|
||||||
for id, it := range w.objects {
|
for id, it := range w.objects {
|
||||||
@@ -243,13 +250,15 @@ func (w *World) reresolveThemeManagedStyles() {
|
|||||||
w.objects[id] = v
|
w.objects[id] = v
|
||||||
|
|
||||||
default:
|
default:
|
||||||
panic("reresolveThemeManagedStyles: unknown item type")
|
panic("refreshThemeManagedStyles: unknown item type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.ForceFullRedrawNext()
|
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 {
|
func (w *World) derivedStyleID(base StyleID, ov StyleOverride) StyleID {
|
||||||
if ov.IsZero() {
|
if ov.IsZero() {
|
||||||
return base
|
return base
|
||||||
@@ -297,7 +306,7 @@ func (w *World) rebuildIndexFromLastState() {
|
|||||||
return
|
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
|
w.indexDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,12 +607,16 @@ func (w *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cam
|
|||||||
w.index.viewportH = viewportHeightPx
|
w.index.viewportH = viewportHeightPx
|
||||||
w.index.zoomFp = zoomFp
|
w.index.zoomFp = zoomFp
|
||||||
|
|
||||||
w.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
|
w.rebuildIndexForViewportZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||||
w.indexDirty = false
|
w.indexDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
|
// rebuildIndexForViewportZoomFp rebuilds the spatial grid for a particular
|
||||||
func (w *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
|
// 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)
|
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||||
|
|
||||||
cellsAcrossMin := 8
|
cellsAcrossMin := 8
|
||||||
@@ -659,3 +672,343 @@ func circleRadiusEffFp(rawRadiusFp, circleRadiusScaleFp int) int {
|
|||||||
}
|
}
|
||||||
return int(v)
|
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