themes and styles
This commit is contained in:
+13
-13
@@ -245,11 +245,21 @@ func (e *editor) BuildUI(w fyne.Window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *editor) loadWorld(w *world.World) {
|
func (e *editor) loadWorld(w *world.World) {
|
||||||
|
if e.world == nil {
|
||||||
|
w.SetCircleRadiusScaleFp(world.SCALE / 4)
|
||||||
e.world = w
|
e.world = w
|
||||||
// TODO: store camera position in user settings
|
// TODO: store camera position in user settings
|
||||||
e.wp.CameraXWorldFp = w.W / 2
|
e.wp.CameraXWorldFp = w.W / 2
|
||||||
e.wp.CameraYWorldFp = w.H / 2
|
e.wp.CameraYWorldFp = w.H / 2
|
||||||
|
e.world.SetTheme(world.ThemeDark)
|
||||||
e.updateSizes()
|
e.updateSizes()
|
||||||
|
} else {
|
||||||
|
if e.world.Theme().ID() == "theme.light.v1" {
|
||||||
|
e.world.SetTheme(world.ThemeDark)
|
||||||
|
} else {
|
||||||
|
e.world.SetTheme(world.ThemeLight)
|
||||||
|
}
|
||||||
|
}
|
||||||
e.RequestRefresh()
|
e.RequestRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,24 +314,14 @@ func mockWorldInit(w *world.World) {
|
|||||||
StrokeDashes: new([]float64{10.}),
|
StrokeDashes: new([]float64{10.}),
|
||||||
})
|
})
|
||||||
|
|
||||||
discStyle := w.AddStyleCircle(world.StyleOverride{
|
if _, err := w.AddCircle(150, 150, 50); err != nil {
|
||||||
FillColor: color.RGBA{R: 255, G: 255, B: 0, A: 255},
|
|
||||||
})
|
|
||||||
|
|
||||||
circleStyle := w.AddStyleCircle(world.StyleOverride{
|
|
||||||
FillColor: world.TransparentFill(),
|
|
||||||
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
|
||||||
StrokeWidthPx: new(4.0),
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := w.AddCircle(150, 150, 50, world.CircleWithStyleID(discStyle)); err != nil {
|
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := w.AddCircle(150, 299, 30, world.CircleWithStyleID(circleStyle)); err != nil {
|
if _, err := w.AddCircle(150, 299, 30); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if _, err := w.AddCircle(299, 150, 30, world.CircleWithStyleID(circleStyle)); err != nil {
|
if _, err := w.AddCircle(299, 150, 30); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
@@ -74,6 +74,10 @@ type PrimitiveDrawer interface {
|
|||||||
// Clear operations must NOT change clip state.
|
// Clear operations must NOT change clip state.
|
||||||
ClearAllTo(bg color.Color)
|
ClearAllTo(bg color.Color)
|
||||||
ClearRectTo(x, y, w, h int, bg color.Color)
|
ClearRectTo(x, y, w, h int, bg color.Color)
|
||||||
|
|
||||||
|
DrawImage(img image.Image, x, y int)
|
||||||
|
|
||||||
|
DrawImageScaled(img image.Image, x, y, w, h int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
|
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
|
||||||
@@ -336,3 +340,28 @@ func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GGDrawer) DrawImage(img image.Image, x, y int) {
|
||||||
|
g.DC.DrawImage(img, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GGDrawer) DrawImageScaled(img image.Image, x, y, w, h int) {
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
srcW := b.Dx()
|
||||||
|
srcH := b.Dy()
|
||||||
|
if srcW <= 0 || srcH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.DC.Push()
|
||||||
|
// Translate to destination top-left.
|
||||||
|
g.DC.Translate(float64(x), float64(y))
|
||||||
|
// Scale so that the source bounds map to (w,h).
|
||||||
|
g.DC.Scale(float64(w)/float64(srcW), float64(h)/float64(srcH))
|
||||||
|
// Draw at origin in the scaled coordinate system.
|
||||||
|
g.DC.DrawImage(img, 0, 0)
|
||||||
|
g.DC.Pop()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package world
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -239,3 +240,11 @@ func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
|
|||||||
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
|
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
|
||||||
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
+9
-5
@@ -127,7 +127,7 @@ func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gather candidates from grid cells, dedupe by ID.
|
// Gather candidates from grid cells, dedupe by ID.
|
||||||
cand := make(map[PrimitiveID]MapItem, 32)
|
cand := make(map[PrimitiveID]struct{}, 32)
|
||||||
for _, r := range rects {
|
for _, r := range rects {
|
||||||
colStart := w.worldToCellX(r.minX)
|
colStart := w.worldToCellX(r.minX)
|
||||||
colEnd := w.worldToCellX(r.maxX - 1)
|
colEnd := w.worldToCellX(r.maxX - 1)
|
||||||
@@ -138,7 +138,7 @@ func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx in
|
|||||||
for col := colStart; col <= colEnd; col++ {
|
for col := colStart; col <= colEnd; col++ {
|
||||||
cell := w.grid[row][col]
|
cell := w.grid[row][col]
|
||||||
for _, it := range cell {
|
for _, it := range cell {
|
||||||
cand[it.ID()] = it
|
cand[it.ID()] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +148,12 @@ func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx in
|
|||||||
out = out[:0]
|
out = out[:0]
|
||||||
limit := cap(out)
|
limit := cap(out)
|
||||||
|
|
||||||
for _, it := range cand {
|
for id := range cand {
|
||||||
h, ok := w.hitOne(it, cursorX, cursorY, zoomFp, allowWrap)
|
cur, ok := w.objects[id]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -210,7 +214,7 @@ func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit,
|
|||||||
// Unknown style should not happen; treat as no-hit rather than panic.
|
// Unknown style should not happen; treat as no-hit rather than panic.
|
||||||
return Hit{}, false
|
return Hit{}, false
|
||||||
}
|
}
|
||||||
return hitCircle(v, style, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
return hitCircle(v, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), style, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
||||||
|
|
||||||
case Line:
|
case Line:
|
||||||
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH in
|
|||||||
return Hit{}, false
|
return Hit{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
|
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)
|
slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx)
|
||||||
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
|
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
|
|
||||||
// Determine if circle is point-like at current zoom.
|
// Determine if circle is point-like at current zoom.
|
||||||
// IMPORTANT: point-like disc behavior applies only for filled circles.
|
// IMPORTANT: point-like disc behavior applies only for filled circles.
|
||||||
rPx := worldSpanFixedToCanvasPx(c.Radius, zoomFp)
|
rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp)
|
||||||
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
|
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
|
||||||
|
|
||||||
var dx, dy int64
|
var dx, dy int64
|
||||||
@@ -78,8 +78,8 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
// Treat as a disc with minimum visible radius in px.
|
// Treat as a disc with minimum visible radius in px.
|
||||||
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
||||||
effR := minRW
|
effR := minRW
|
||||||
if c.Radius > effR {
|
if effRadiusFp > effR {
|
||||||
effR = c.Radius
|
effR = effRadiusFp
|
||||||
}
|
}
|
||||||
r := effR + slopW
|
r := effR + slopW
|
||||||
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
||||||
@@ -91,7 +91,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
DistanceSq: ds,
|
DistanceSq: ds,
|
||||||
X: c.X,
|
X: c.X,
|
||||||
Y: c.Y,
|
Y: c.Y,
|
||||||
Radius: c.Radius,
|
Radius: effRadiusFp,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
return Hit{}, false
|
return Hit{}, false
|
||||||
@@ -99,7 +99,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
|
|
||||||
// Filled circle: hit-test by disc (surface).
|
// Filled circle: hit-test by disc (surface).
|
||||||
if fillVisible {
|
if fillVisible {
|
||||||
r := c.Radius + slopW
|
r := effRadiusFp + slopW
|
||||||
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
|
||||||
return Hit{
|
return Hit{
|
||||||
ID: c.Id,
|
ID: c.Id,
|
||||||
@@ -109,7 +109,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
DistanceSq: ds,
|
DistanceSq: ds,
|
||||||
X: c.X,
|
X: c.X,
|
||||||
Y: c.Y,
|
Y: c.Y,
|
||||||
Radius: c.Radius,
|
Radius: effRadiusFp,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
return Hit{}, false
|
return Hit{}, false
|
||||||
@@ -118,7 +118,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
// Stroke-only circle: ring hit, but NEVER at exact center.
|
// Stroke-only circle: ring hit, but NEVER at exact center.
|
||||||
// For very small circles, expand the effective radius to a minimum visible size
|
// For very small circles, expand the effective radius to a minimum visible size
|
||||||
// so that ring selection remains practical, while still excluding center.
|
// so that ring selection remains practical, while still excluding center.
|
||||||
effR := c.Radius
|
effR := effRadiusFp
|
||||||
if rPx < CirclePointLikeMinRadiusPx {
|
if rPx < CirclePointLikeMinRadiusPx {
|
||||||
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
|
||||||
if minRW > effR {
|
if minRW > effR {
|
||||||
@@ -145,7 +145,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo
|
|||||||
DistanceSq: ds,
|
DistanceSq: ds,
|
||||||
X: c.X,
|
X: c.X,
|
||||||
Y: c.Y,
|
Y: c.Y,
|
||||||
Radius: c.Radius,
|
Radius: effRadiusFp,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
return Hit{}, false
|
return Hit{}, false
|
||||||
|
|||||||
@@ -150,3 +150,39 @@ func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
|
|||||||
require.NotEmpty(t, hits)
|
require.NotEmpty(t, hits)
|
||||||
require.Equal(t, KindCircle, hits[0].Kind)
|
require.Equal(t, KindCircle, hits[0].Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := NewWorld(10, 10)
|
||||||
|
w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults
|
||||||
|
w.IndexOnViewportChange(100, 100, 1.0)
|
||||||
|
|
||||||
|
// raw radius=2 units, centered at (5,5)
|
||||||
|
_, err := w.AddCircle(5, 5, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// scale=2 => eff radius=4
|
||||||
|
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
|
||||||
|
w.Reindex()
|
||||||
|
|
||||||
|
params := RenderParams{
|
||||||
|
ViewportWidthPx: 100,
|
||||||
|
ViewportHeightPx: 100,
|
||||||
|
MarginXPx: 0,
|
||||||
|
MarginYPx: 0,
|
||||||
|
CameraXWorldFp: 5 * SCALE,
|
||||||
|
CameraYWorldFp: 5 * SCALE,
|
||||||
|
CameraZoom: 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap at +4 px from center should hit (eff radius 4).
|
||||||
|
buf := make([]Hit, 0, 8)
|
||||||
|
hits, err := w.HitTest(buf, ¶ms, 50+4, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, hits)
|
||||||
|
require.Equal(t, KindCircle, hits[0].Kind)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ type gridCell struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestWorld(wReal, hReal int) *World {
|
func newTestWorld(wReal, hReal int) *World {
|
||||||
return NewWorld(wReal, hReal)
|
w := NewWorld(wReal, hReal)
|
||||||
|
w.SetCircleRadiusScaleFp(SCALE)
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func countObjectInGrid(g *World, id PrimitiveID) int {
|
func countObjectInGrid(g *World, id PrimitiveID) int {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type PointOptions struct {
|
|||||||
Priority int
|
Priority int
|
||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
Class PointClassID
|
||||||
|
|
||||||
HitSlopPx int
|
HitSlopPx int
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ func defaultPointOptions() PointOptions {
|
|||||||
return PointOptions{
|
return PointOptions{
|
||||||
Priority: DefaultPriorityPoint,
|
Priority: DefaultPriorityPoint,
|
||||||
StyleID: StyleIDDefaultPoint,
|
StyleID: StyleIDDefaultPoint,
|
||||||
|
Class: PointClassDefault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ func PointWithStyleID(id StyleID) PointOpt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PointWithClass(c PointClassID) PointOpt {
|
||||||
|
return func(o *PointOptions) { o.Class = c }
|
||||||
|
}
|
||||||
|
|
||||||
// PointWithStyleOverride derives a style from default point style and applies overrides.
|
// PointWithStyleOverride derives a style from default point style and applies overrides.
|
||||||
// If you also set StyleID, StyleID wins.
|
// If you also set StyleID, StyleID wins.
|
||||||
func PointWithStyleOverride(ov StyleOverride) PointOpt {
|
func PointWithStyleOverride(ov StyleOverride) PointOpt {
|
||||||
@@ -56,6 +62,7 @@ type CircleOptions struct {
|
|||||||
Priority int
|
Priority int
|
||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
Class CircleClassID
|
||||||
|
|
||||||
HitSlopPx int
|
HitSlopPx int
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ func defaultCircleOptions() CircleOptions {
|
|||||||
return CircleOptions{
|
return CircleOptions{
|
||||||
Priority: DefaultPriorityCircle,
|
Priority: DefaultPriorityCircle,
|
||||||
StyleID: StyleIDDefaultCircle,
|
StyleID: StyleIDDefaultCircle,
|
||||||
|
Class: CircleClassDefault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +91,10 @@ func CircleWithStyleID(id StyleID) CircleOpt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CircleWithClass(c CircleClassID) CircleOpt {
|
||||||
|
return func(o *CircleOptions) { o.Class = c }
|
||||||
|
}
|
||||||
|
|
||||||
func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
|
func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
|
||||||
return func(o *CircleOptions) {
|
return func(o *CircleOptions) {
|
||||||
o.Override = ov
|
o.Override = ov
|
||||||
@@ -99,6 +111,7 @@ type LineOptions struct {
|
|||||||
Priority int
|
Priority int
|
||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
Class LineClassID
|
||||||
|
|
||||||
HitSlopPx int
|
HitSlopPx int
|
||||||
|
|
||||||
@@ -109,6 +122,7 @@ func defaultLineOptions() LineOptions {
|
|||||||
return LineOptions{
|
return LineOptions{
|
||||||
Priority: DefaultPriorityLine,
|
Priority: DefaultPriorityLine,
|
||||||
StyleID: StyleIDDefaultLine,
|
StyleID: StyleIDDefaultLine,
|
||||||
|
Class: LineClassDefault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +140,10 @@ func LineWithStyleID(id StyleID) LineOpt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LineWithClass(c LineClassID) LineOpt {
|
||||||
|
return func(o *LineOptions) { o.Class = c }
|
||||||
|
}
|
||||||
|
|
||||||
func LineWithStyleOverride(ov StyleOverride) LineOpt {
|
func LineWithStyleOverride(ov StyleOverride) LineOpt {
|
||||||
return func(o *LineOptions) {
|
return func(o *LineOptions) {
|
||||||
o.Override = ov
|
o.Override = ov
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ type MapItem interface {
|
|||||||
ID() PrimitiveID
|
ID() PrimitiveID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type styleBase uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
styleBaseFixed styleBase = iota
|
||||||
|
styleBaseThemeLine
|
||||||
|
styleBaseThemeCircle
|
||||||
|
styleBaseThemePoint
|
||||||
|
)
|
||||||
|
|
||||||
// Point is a point primitive in fixed-point world coordinates.
|
// Point is a point primitive in fixed-point world coordinates.
|
||||||
type Point struct {
|
type Point struct {
|
||||||
Id PrimitiveID
|
Id PrimitiveID
|
||||||
@@ -18,6 +27,11 @@ type Point struct {
|
|||||||
Priority int
|
Priority int
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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).
|
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
|
||||||
// 0 means "use primitive default".
|
// 0 means "use primitive default".
|
||||||
@@ -34,6 +48,11 @@ type Line struct {
|
|||||||
Priority int
|
Priority int
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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).
|
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
|
||||||
// 0 means "use primitive default".
|
// 0 means "use primitive default".
|
||||||
@@ -50,6 +69,11 @@ type Circle struct {
|
|||||||
Priority int
|
Priority int
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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).
|
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
|
||||||
// 0 means "use primitive default".
|
// 0 means "use primitive default".
|
||||||
|
|||||||
@@ -171,14 +171,22 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bg := color.RGBA{A: 255} // default black
|
var bg color.Color = color.RGBA{A: 255} // default black
|
||||||
|
|
||||||
if params.Options != nil && params.Options.BackgroundColor != nil {
|
if params.Options != nil && params.Options.BackgroundColor != nil {
|
||||||
if v, ok := params.Options.BackgroundColor.(color.RGBA); !ok {
|
if v, ok := params.Options.BackgroundColor.(color.RGBA); !ok {
|
||||||
panic("Options.BackgroundColor is not color.RGBA type")
|
panic("Options.BackgroundColor is not color.RGBA type")
|
||||||
} else {
|
} else {
|
||||||
bg = v
|
bg = v
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tc := w.Theme().BackgroundColor()
|
||||||
|
if alphaNonZero(tc) {
|
||||||
|
bg = tc
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if !params.Debug {
|
if !params.Debug {
|
||||||
@@ -217,8 +225,6 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
policy = *params.Options.Incremental
|
policy = *params.Options.Incremental
|
||||||
}
|
}
|
||||||
|
|
||||||
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
|
||||||
|
|
||||||
// --- Try incremental path first when state is initialized and geometry matches ---
|
// --- Try incremental path first when state is initialized and geometry matches ---
|
||||||
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
||||||
if derr == nil {
|
if derr == nil {
|
||||||
@@ -243,6 +249,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
if len(toDraw) > 0 {
|
if len(toDraw) > 0 {
|
||||||
for _, r := range toDraw {
|
for _, r := range toDraw {
|
||||||
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
||||||
|
w.drawBackground(drawer, params, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
plan, err := w.buildRenderPlanStageA(params)
|
||||||
@@ -295,6 +302,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
|
|
||||||
for _, r := range dirtyToDraw {
|
for _, r := range dirtyToDraw {
|
||||||
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
|
||||||
|
w.drawBackground(drawer, params, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additionally redraw a bounded portion of deferred dirty regions.
|
// Additionally redraw a bounded portion of deferred dirty regions.
|
||||||
@@ -328,6 +336,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawer.ClearAllTo(bg)
|
drawer.ClearAllTo(bg)
|
||||||
|
w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()})
|
||||||
w.drawPlanSinglePass(drawer, plan, allowWrap)
|
w.drawPlanSinglePass(drawer, plan, allowWrap)
|
||||||
return w.CommitFullRedrawState(params)
|
return w.CommitFullRedrawState(params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package world
|
package world
|
||||||
|
|
||||||
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
|
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
|
||||||
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool) {
|
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool, circleRadiusScaleFp int) {
|
||||||
for _, td := range plan.Tiles {
|
for _, td := range plan.Tiles {
|
||||||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -30,13 +30,14 @@ func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH
|
|||||||
|
|
||||||
for _, c := range circles {
|
for _, c := range circles {
|
||||||
var shifts []wrapShift
|
var shifts []wrapShift
|
||||||
|
effRadius := circleRadiusEffFp(c.Radius, circleRadiusScaleFp)
|
||||||
if allowWrap {
|
if allowWrap {
|
||||||
shifts = circleWrapShifts(c, worldW, worldH)
|
shifts = circleWrapShifts(c.X, c.Y, effRadius, worldW, worldH)
|
||||||
} else {
|
} else {
|
||||||
shifts = []wrapShift{{dx: 0, dy: 0}}
|
shifts = []wrapShift{{dx: 0, dy: 0}}
|
||||||
}
|
}
|
||||||
for _, s := range shifts {
|
for _, s := range shifts {
|
||||||
if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) {
|
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})
|
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,27 +76,27 @@ type wrapShift struct {
|
|||||||
// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render
|
// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render
|
||||||
// all torus copies of the circle inside the canonical world domain.
|
// all torus copies of the circle inside the canonical world domain.
|
||||||
// The (0,0) shift is always present.
|
// The (0,0) shift is always present.
|
||||||
func circleWrapShifts(c Circle, worldW, worldH int) []wrapShift {
|
func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift {
|
||||||
// If radius covers the whole axis, additional copies are not useful.
|
// If radius covers the whole axis, additional copies are not useful.
|
||||||
// (One copy already covers everything under any reasonable clip.)
|
// (One copy already covers everything under any reasonable clip.)
|
||||||
if c.Radius >= worldW || c.Radius >= worldH {
|
if radiusFp >= worldW || radiusFp >= worldH {
|
||||||
return []wrapShift{{dx: 0, dy: 0}}
|
return []wrapShift{{dx: 0, dy: 0}}
|
||||||
}
|
}
|
||||||
|
|
||||||
xShifts := []int{0}
|
xShifts := []int{0}
|
||||||
yShifts := []int{0}
|
yShifts := []int{0}
|
||||||
|
|
||||||
if c.X+c.Radius >= worldW {
|
if cx+radiusFp >= worldW {
|
||||||
xShifts = append(xShifts, -worldW)
|
xShifts = append(xShifts, -worldW)
|
||||||
}
|
}
|
||||||
if c.X-c.Radius < 0 {
|
if cx-radiusFp < 0 {
|
||||||
xShifts = append(xShifts, worldW)
|
xShifts = append(xShifts, worldW)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Y+c.Radius >= worldH {
|
if cy+radiusFp >= worldH {
|
||||||
yShifts = append(yShifts, -worldH)
|
yShifts = append(yShifts, -worldH)
|
||||||
}
|
}
|
||||||
if c.Y-c.Radius < 0 {
|
if cy-radiusFp < 0 {
|
||||||
yShifts = append(yShifts, worldH)
|
yShifts = append(yShifts, worldH)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ func circleWrapShifts(c Circle, worldW, worldH int) []wrapShift {
|
|||||||
|
|
||||||
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
|
// 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.
|
// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis.
|
||||||
func circleCopyIntersectsTile(c Circle, dx, dy int, tile WorldTile, worldW, worldH int) bool {
|
func circleCopyIntersectsTile(cx, cy, radiusFp, dx, dy int, tile WorldTile, worldW, worldH int) bool {
|
||||||
// Unwrapped tile segment bounds.
|
// Unwrapped tile segment bounds.
|
||||||
segMinX := tile.OffsetX + tile.Rect.minX
|
segMinX := tile.OffsetX + tile.Rect.minX
|
||||||
segMaxX := tile.OffsetX + tile.Rect.maxX
|
segMaxX := tile.OffsetX + tile.Rect.maxX
|
||||||
@@ -118,13 +119,13 @@ func circleCopyIntersectsTile(c Circle, dx, dy int, tile WorldTile, worldW, worl
|
|||||||
segMaxY := tile.OffsetY + tile.Rect.maxY
|
segMaxY := tile.OffsetY + tile.Rect.maxY
|
||||||
|
|
||||||
// Circle bbox in the same unwrapped space (apply shift + tile offset).
|
// Circle bbox in the same unwrapped space (apply shift + tile offset).
|
||||||
cx := c.X + tile.OffsetX + dx
|
cx = cx + tile.OffsetX + dx
|
||||||
cy := c.Y + tile.OffsetY + dy
|
cy = cy + tile.OffsetY + dy
|
||||||
|
|
||||||
minX := cx - c.Radius
|
minX := cx - radiusFp
|
||||||
maxX := cx + c.Radius
|
maxX := cx + radiusFp
|
||||||
minY := cy - c.Radius
|
minY := cy - radiusFp
|
||||||
maxY := cy + c.Radius
|
maxY := cy + radiusFp
|
||||||
|
|
||||||
// Treat bbox as half-open for intersection checks.
|
// Treat bbox as half-open for intersection checks.
|
||||||
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
|
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||||
|
|
||||||
// Expect 4 circle copies, one per tile that covers the expanded canvas.
|
// Expect 4 circle copies, one per tile that covers the expanded canvas.
|
||||||
wantNames := []string{
|
wantNames := []string{
|
||||||
@@ -119,7 +119,7 @@ func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||||
|
|
||||||
// No circles => no commands.
|
// No circles => no commands.
|
||||||
require.Empty(t, d.Commands())
|
require.Empty(t, d.Commands())
|
||||||
@@ -150,7 +150,7 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||||
|
|
||||||
// There should be at least one AddCircle.
|
// There should be at least one AddCircle.
|
||||||
cmds := d.CommandsByName("AddCircle")
|
cmds := d.CommandsByName("AddCircle")
|
||||||
@@ -167,6 +167,7 @@ func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := NewWorld(10, 10)
|
w := NewWorld(10, 10)
|
||||||
|
w.SetCircleRadiusScaleFp(SCALE)
|
||||||
w.resetGrid(2 * SCALE)
|
w.resetGrid(2 * SCALE)
|
||||||
|
|
||||||
_, err := w.AddCircle(9, 9, 2)
|
_, err := w.AddCircle(9, 9, 2)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi
|
|||||||
|
|
||||||
// World 10x10 units => 10px at zoom=1 when viewport==world.
|
// World 10x10 units => 10px at zoom=1 when viewport==world.
|
||||||
w := NewWorld(10, 10)
|
w := NewWorld(10, 10)
|
||||||
|
w.SetCircleRadiusScaleFp(SCALE)
|
||||||
w.resetGrid(2 * SCALE)
|
w.resetGrid(2 * SCALE)
|
||||||
|
|
||||||
type tc struct {
|
type tc struct {
|
||||||
@@ -74,7 +75,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w2.W, w2.H, true)
|
drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp)
|
||||||
|
|
||||||
cmds := d.CommandsByName("AddCircle")
|
cmds := d.CommandsByName("AddCircle")
|
||||||
require.Len(t, cmds, len(tt.wantCenters))
|
require.Len(t, cmds, len(tt.wantCenters))
|
||||||
|
|||||||
@@ -74,7 +74,14 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
items := make([]drawItem, 0, len(td.Candidates))
|
items := make([]drawItem, 0, len(td.Candidates))
|
||||||
|
|
||||||
for _, it := range td.Candidates {
|
for _, it := range td.Candidates {
|
||||||
switch v := it.(type) {
|
id := it.ID()
|
||||||
|
cur, ok := w.objects[id]
|
||||||
|
if !ok {
|
||||||
|
// Stale grid entry (object removed). Skip.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := cur.(type) {
|
||||||
case Point:
|
case Point:
|
||||||
vv := v
|
vv := v
|
||||||
items = append(items, drawItem{
|
items = append(items, drawItem{
|
||||||
|
|||||||
@@ -44,16 +44,17 @@ func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td Tile
|
|||||||
|
|
||||||
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
|
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
|
||||||
var shifts []wrapShift
|
var shifts []wrapShift
|
||||||
|
effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
|
||||||
if allowWrap {
|
if allowWrap {
|
||||||
shifts = circleWrapShifts(c, w.W, w.H)
|
shifts = circleWrapShifts(c.X, c.Y, effRadius, w.W, w.H)
|
||||||
} else {
|
} else {
|
||||||
shifts = []wrapShift{{dx: 0, dy: 0}}
|
shifts = []wrapShift{{dx: 0, dy: 0}}
|
||||||
}
|
}
|
||||||
|
|
||||||
rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp)
|
rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp)
|
||||||
|
|
||||||
for _, s := range shifts {
|
for _, s := range shifts {
|
||||||
if allowWrap && !circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, w.W, w.H) {
|
if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) {
|
|||||||
|
|
||||||
// Execute all three passes over the same plan.
|
// Execute all three passes over the same plan.
|
||||||
drawPointsFromPlan(d, plan, true)
|
drawPointsFromPlan(d, plan, true)
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||||
drawLinesFromPlan(d, plan, w.W, w.H, true)
|
drawLinesFromPlan(d, plan, w.W, w.H, true)
|
||||||
|
|
||||||
names := d.CommandNames()
|
names := d.CommandNames()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
|
|||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawPointsFromPlan(d, plan, true)
|
drawPointsFromPlan(d, plan, true)
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)
|
||||||
|
|
||||||
names := d.CommandNames()
|
names := d.CommandNames()
|
||||||
require.Contains(t, names, "AddPoint")
|
require.Contains(t, names, "AddPoint")
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := NewWorld(10, 10)
|
w := NewWorld(10, 10)
|
||||||
|
w.SetCircleRadiusScaleFp(SCALE)
|
||||||
w.resetGrid(2 * SCALE)
|
w.resetGrid(2 * SCALE)
|
||||||
|
|
||||||
// Circle near the left edge crossing X=0 boundary.
|
// Circle near the left edge crossing X=0 boundary.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -205,3 +205,30 @@ func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID
|
|||||||
t.styles[id] = derived
|
t.styles[id] = derived
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddStyle stores a fully resolved style as a new StyleID.
|
||||||
|
// It defensively copies slice fields.
|
||||||
|
func (t *StyleTable) AddStyle(s Style) StyleID {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
id := t.nextID
|
||||||
|
t.nextID++
|
||||||
|
|
||||||
|
if s.StrokeDashes != nil {
|
||||||
|
cp := make([]float64, len(s.StrokeDashes))
|
||||||
|
copy(cp, s.StrokeDashes)
|
||||||
|
s.StrokeDashes = cp
|
||||||
|
}
|
||||||
|
|
||||||
|
t.styles[id] = s
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of styles stored in the table.
|
||||||
|
// Intended for tests/diagnostics.
|
||||||
|
func (t *StyleTable) Count() int {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return len(t.styles)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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 {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
+389
-121
@@ -18,6 +18,10 @@ type indexState struct {
|
|||||||
viewportH int
|
viewportH int
|
||||||
zoomFp int
|
zoomFp int
|
||||||
}
|
}
|
||||||
|
type derivedStyleKey struct {
|
||||||
|
base StyleID
|
||||||
|
fp uint64
|
||||||
|
}
|
||||||
|
|
||||||
// World stores torus world dimensions, all registered objects,
|
// World stores torus world dimensions, all registered objects,
|
||||||
// and the grid-based spatial index built for the current viewport settings.
|
// and the grid-based spatial index built for the current viewport settings.
|
||||||
@@ -27,7 +31,14 @@ type World struct {
|
|||||||
cellSize int
|
cellSize int
|
||||||
rows, cols int
|
rows, cols int
|
||||||
objects map[PrimitiveID]MapItem
|
objects map[PrimitiveID]MapItem
|
||||||
|
|
||||||
styles *StyleTable
|
styles *StyleTable
|
||||||
|
theme StyleTheme
|
||||||
|
themeDefaultLineStyleID StyleID
|
||||||
|
themeDefaultCircleStyleID StyleID
|
||||||
|
themeDefaultPointStyleID StyleID
|
||||||
|
|
||||||
|
circleRadiusScaleFp int // fixed-point, 1.0 == SCALE
|
||||||
|
|
||||||
// PrimitiveID allocator state.
|
// PrimitiveID allocator state.
|
||||||
nextID PrimitiveID
|
nextID PrimitiveID
|
||||||
@@ -38,6 +49,7 @@ type World struct {
|
|||||||
index indexState
|
index indexState
|
||||||
|
|
||||||
renderState rendererIncrementalState
|
renderState rendererIncrementalState
|
||||||
|
derivedCache map[derivedStyleKey]StyleID
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorld constructs a new world with the given real dimensions.
|
// NewWorld constructs a new world with the given real dimensions.
|
||||||
@@ -52,103 +64,247 @@ func NewWorld(width, height int) *World {
|
|||||||
cellSize: 1,
|
cellSize: 1,
|
||||||
objects: make(map[PrimitiveID]MapItem),
|
objects: make(map[PrimitiveID]MapItem),
|
||||||
styles: NewStyleTable(),
|
styles: NewStyleTable(),
|
||||||
|
theme: DefaultTheme{},
|
||||||
|
|
||||||
|
// At startup, "theme defaults" point to conservative built-ins.
|
||||||
|
themeDefaultLineStyleID: StyleIDDefaultLine,
|
||||||
|
themeDefaultCircleStyleID: StyleIDDefaultCircle,
|
||||||
|
themeDefaultPointStyleID: StyleIDDefaultPoint,
|
||||||
|
|
||||||
|
circleRadiusScaleFp: SCALE,
|
||||||
|
|
||||||
|
derivedCache: make(map[derivedStyleKey]StyleID, 128),
|
||||||
|
|
||||||
nextID: 1, // 0 is reserved as "invalid"
|
nextID: 1, // 0 is reserved as "invalid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter.
|
// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter.
|
||||||
// It returns an error if the ID space is exhausted.
|
// It returns an error if the ID space is exhausted.
|
||||||
func (g *World) allocID() (PrimitiveID, error) {
|
func (w *World) allocID() (PrimitiveID, error) {
|
||||||
if n := len(g.freeIDs); n > 0 {
|
if n := len(w.freeIDs); n > 0 {
|
||||||
id := g.freeIDs[n-1]
|
id := w.freeIDs[n-1]
|
||||||
g.freeIDs = g.freeIDs[:n-1]
|
w.freeIDs = w.freeIDs[:n-1]
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
if g.nextID == PrimitiveID(^uint32(0)) {
|
if w.nextID == PrimitiveID(^uint32(0)) {
|
||||||
return 0, errIDExhausted
|
return 0, errIDExhausted
|
||||||
}
|
}
|
||||||
id := g.nextID
|
id := w.nextID
|
||||||
g.nextID++
|
w.nextID++
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// freeID returns an id back to the pool. It is safe to call only after the object is removed.
|
// freeID returns an id back to the pool. It is safe to call only after the object is removed.
|
||||||
func (g *World) freeID(id PrimitiveID) {
|
func (w *World) freeID(id PrimitiveID) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.freeIDs = append(g.freeIDs, id)
|
w.freeIDs = append(w.freeIDs, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
|
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
|
||||||
// lies inside the world bounds: [0, W) x [0, H).
|
// lies inside the world bounds: [0, W) x [0, H).
|
||||||
func (g *World) checkCoordinate(xf, yf int) bool {
|
func (w *World) checkCoordinate(xf, yf int) bool {
|
||||||
if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H {
|
if xf < 0 || xf >= w.W || yf < 0 || yf >= w.H {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddStyleLine creates a new line style derived from the default line style.
|
// AddStyleLine creates a new line style derived from the default line style.
|
||||||
func (g *World) AddStyleLine(override StyleOverride) StyleID {
|
func (w *World) AddStyleLine(override StyleOverride) StyleID {
|
||||||
return g.styles.AddDerived(StyleIDDefaultLine, override)
|
return w.styles.AddDerived(StyleIDDefaultLine, override)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddStyleCircle creates a new circle style derived from the default circle style.
|
// AddStyleCircle creates a new circle style derived from the default circle style.
|
||||||
func (g *World) AddStyleCircle(override StyleOverride) StyleID {
|
func (w *World) AddStyleCircle(override StyleOverride) StyleID {
|
||||||
return g.styles.AddDerived(StyleIDDefaultCircle, override)
|
return w.styles.AddDerived(StyleIDDefaultCircle, override)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddStylePoint creates a new point style derived from the default point style.
|
// AddStylePoint creates a new point style derived from the default point style.
|
||||||
func (g *World) AddStylePoint(override StyleOverride) StyleID {
|
func (w *World) AddStylePoint(override StyleOverride) StyleID {
|
||||||
return g.styles.AddDerived(StyleIDDefaultPoint, override)
|
return w.styles.AddDerived(StyleIDDefaultPoint, override)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme returns the current theme. It is never nil.
|
||||||
|
func (w *World) Theme() StyleTheme {
|
||||||
|
if w.theme == nil {
|
||||||
|
return DefaultTheme{}
|
||||||
|
}
|
||||||
|
return w.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTheme updates the world's current theme.
|
||||||
|
// Step 1 behavior:
|
||||||
|
// - Does NOT mutate built-in default styles (1/2/3).
|
||||||
|
// - Materializes three theme default styles as new StyleIDs in the style table.
|
||||||
|
// - New objects (and later, theme-relative ones) can use these IDs.
|
||||||
|
// - Forces next render to full redraw.
|
||||||
|
func (w *World) SetTheme(theme StyleTheme) {
|
||||||
|
if theme == nil {
|
||||||
|
theme = DefaultTheme{}
|
||||||
|
}
|
||||||
|
// fmt.Println("current theme:", w.theme.ID())
|
||||||
|
w.theme = theme
|
||||||
|
// fmt.Println("new theme:", w.theme.ID())
|
||||||
|
|
||||||
|
// Drop derived cache when theme changes to avoid unbounded growth.
|
||||||
|
for k := range w.derivedCache {
|
||||||
|
delete(w.derivedCache, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materialize theme base styles as new IDs.
|
||||||
|
w.themeDefaultLineStyleID = w.styles.AddStyle(theme.LineStyle())
|
||||||
|
w.themeDefaultCircleStyleID = w.styles.AddStyle(theme.CircleStyle())
|
||||||
|
w.themeDefaultPointStyleID = w.styles.AddStyle(theme.PointStyle())
|
||||||
|
|
||||||
|
w.reresolveThemeManagedStyles()
|
||||||
|
|
||||||
|
// Full redraw to apply new background and base styles.
|
||||||
|
w.renderState.Reset()
|
||||||
|
// w.forceFullRedraw = true
|
||||||
|
w.ForceFullRedrawNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *World) themeBaseStyleID(base styleBase) StyleID {
|
||||||
|
switch base {
|
||||||
|
case styleBaseThemeLine:
|
||||||
|
return w.themeDefaultLineStyleID
|
||||||
|
case styleBaseThemeCircle:
|
||||||
|
return w.themeDefaultCircleStyleID
|
||||||
|
case styleBaseThemePoint:
|
||||||
|
return w.themeDefaultPointStyleID
|
||||||
|
default:
|
||||||
|
return StyleIDInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *World) reresolveThemeManagedStyles() {
|
||||||
|
th := w.Theme()
|
||||||
|
|
||||||
|
for id, it := range w.objects {
|
||||||
|
switch v := it.(type) {
|
||||||
|
case Point:
|
||||||
|
if v.Base == styleBaseFixed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseID := w.themeBaseStyleID(v.Base)
|
||||||
|
if baseID == StyleIDInvalid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if ov, ok := th.PointClassOverride(v.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
merged := mergeOverrides(classOv, v.Override)
|
||||||
|
v.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
|
||||||
|
w.objects[id] = v
|
||||||
|
|
||||||
|
case Circle:
|
||||||
|
if v.Base == styleBaseFixed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseID := w.themeBaseStyleID(v.Base)
|
||||||
|
if baseID == StyleIDInvalid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if ov, ok := th.CircleClassOverride(v.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
merged := mergeOverrides(classOv, v.Override)
|
||||||
|
v.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
|
||||||
|
w.objects[id] = v
|
||||||
|
|
||||||
|
case Line:
|
||||||
|
if v.Base == styleBaseFixed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseID := w.themeBaseStyleID(v.Base)
|
||||||
|
if baseID == StyleIDInvalid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if ov, ok := th.LineClassOverride(v.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
merged := mergeOverrides(classOv, v.Override)
|
||||||
|
v.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
|
||||||
|
w.objects[id] = v
|
||||||
|
|
||||||
|
default:
|
||||||
|
panic("reresolveThemeManagedStyles: unknown item type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ForceFullRedrawNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *World) derivedStyleID(base StyleID, ov StyleOverride) StyleID {
|
||||||
|
if ov.IsZero() {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
k := derivedStyleKey{base: base, fp: ov.fingerprint()}
|
||||||
|
if id, ok := w.derivedCache[k]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id := w.styles.AddDerived(base, ov)
|
||||||
|
w.derivedCache[k] = id
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown.
|
// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown.
|
||||||
// It marks the spatial index dirty and triggers an autonomous rebuild if possible.
|
// It marks the spatial index dirty and triggers an autonomous rebuild if possible.
|
||||||
func (g *World) Remove(id PrimitiveID) error {
|
func (w *World) Remove(id PrimitiveID) error {
|
||||||
if _, ok := g.objects[id]; !ok {
|
if _, ok := w.objects[id]; !ok {
|
||||||
return errNoSuchObject
|
return errNoSuchObject
|
||||||
}
|
}
|
||||||
delete(g.objects, id)
|
delete(w.objects, id)
|
||||||
g.freeID(id)
|
w.freeID(id)
|
||||||
|
|
||||||
g.indexDirty = true
|
w.indexDirty = true
|
||||||
g.rebuildIndexFromLastState()
|
w.rebuildIndexFromLastState()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state
|
// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state
|
||||||
// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true.
|
// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true.
|
||||||
func (g *World) Reindex() {
|
func (w *World) Reindex() {
|
||||||
g.indexDirty = true
|
w.indexDirty = true
|
||||||
g.rebuildIndexFromLastState()
|
w.rebuildIndexFromLastState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp
|
// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp
|
||||||
// from renderer state. If that state is not initialized, it does nothing.
|
// from renderer state. If that state is not initialized, it does nothing.
|
||||||
func (g *World) rebuildIndexFromLastState() {
|
func (w *World) rebuildIndexFromLastState() {
|
||||||
if !g.indexDirty {
|
if !w.indexDirty {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !g.index.initialized {
|
if !w.index.initialized {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if g.index.viewportW <= 0 || g.index.viewportH <= 0 || g.index.zoomFp <= 0 {
|
if w.index.viewportW <= 0 || w.index.viewportH <= 0 || w.index.zoomFp <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp)
|
w.indexOnViewportChangeZoomFp(w.index.viewportW, w.index.viewportH, w.index.zoomFp)
|
||||||
g.indexDirty = false
|
w.indexDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddPoint validates and stores a point primitive in the world.
|
// AddPoint validates and stores a point primitive in the world.
|
||||||
// The input coordinates are given in real world units and are converted
|
// The input coordinates are given in real world units and are converted
|
||||||
// to fixed-point before validation.
|
// to fixed-point before validation.
|
||||||
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
|
func (w *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
|
||||||
xf := fixedPoint(x)
|
xf := fixedPoint(x)
|
||||||
yf := fixedPoint(y)
|
yf := fixedPoint(y)
|
||||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
if ok := w.checkCoordinate(xf, yf); !ok {
|
||||||
return 0, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,24 +314,47 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
|
|||||||
opt(&o)
|
opt(&o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
styleID := g.resolvePointStyleID(o)
|
// styleID := g.resolvePointStyleID(o)
|
||||||
|
|
||||||
id, err := g.allocID()
|
id, err := w.allocID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
g.objects[id] = Point{
|
obj := Point{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: xf,
|
X: xf,
|
||||||
Y: yf,
|
Y: yf,
|
||||||
Priority: o.Priority,
|
Priority: o.Priority,
|
||||||
StyleID: styleID,
|
// StyleID: styleID,
|
||||||
HitSlopPx: o.HitSlopPx,
|
HitSlopPx: o.HitSlopPx,
|
||||||
}
|
}
|
||||||
|
|
||||||
g.indexDirty = true
|
obj.Class = o.Class
|
||||||
g.rebuildIndexFromLastState()
|
if o.hasStyleID {
|
||||||
|
obj.Base = styleBaseFixed
|
||||||
|
obj.StyleID = o.StyleID
|
||||||
|
obj.Override = StyleOverride{}
|
||||||
|
} else {
|
||||||
|
obj.Base = styleBaseThemePoint
|
||||||
|
baseID := w.themeDefaultPointStyleID
|
||||||
|
|
||||||
|
// class override from current theme (may be absent)
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if th := w.Theme(); th != nil {
|
||||||
|
if ov, ok := th.PointClassOverride(obj.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeOverrides(classOv, o.Override)
|
||||||
|
obj.Override = o.Override // store only user override; class override comes from theme
|
||||||
|
obj.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.objects[id] = obj
|
||||||
|
w.indexDirty = true
|
||||||
|
w.rebuildIndexFromLastState()
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -183,11 +362,11 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
|
|||||||
// AddCircle validates and stores a circle primitive in the world.
|
// AddCircle validates and stores a circle primitive in the world.
|
||||||
// The center and radius are given in real world units and are converted
|
// The center and radius are given in real world units and are converted
|
||||||
// to fixed-point before validation. A zero radius is allowed.
|
// to fixed-point before validation. A zero radius is allowed.
|
||||||
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) {
|
func (w *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) {
|
||||||
xf := fixedPoint(x)
|
xf := fixedPoint(x)
|
||||||
yf := fixedPoint(y)
|
yf := fixedPoint(y)
|
||||||
|
|
||||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
if ok := w.checkCoordinate(xf, yf); !ok {
|
||||||
return 0, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
@@ -200,25 +379,46 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, erro
|
|||||||
opt(&o)
|
opt(&o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
styleID := g.resolveCircleStyleID(o)
|
|
||||||
|
|
||||||
id, err := g.allocID()
|
id, err := w.allocID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
g.objects[id] = Circle{
|
obj := Circle{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: xf,
|
X: xf,
|
||||||
Y: yf,
|
Y: yf,
|
||||||
Radius: fixedPoint(r),
|
Radius: fixedPoint(r),
|
||||||
Priority: o.Priority,
|
Priority: o.Priority,
|
||||||
StyleID: styleID,
|
|
||||||
HitSlopPx: o.HitSlopPx,
|
HitSlopPx: o.HitSlopPx,
|
||||||
}
|
}
|
||||||
|
|
||||||
g.indexDirty = true
|
obj.Class = o.Class
|
||||||
g.rebuildIndexFromLastState()
|
if o.hasStyleID {
|
||||||
|
obj.Base = styleBaseFixed
|
||||||
|
obj.StyleID = o.StyleID
|
||||||
|
obj.Override = StyleOverride{}
|
||||||
|
} else {
|
||||||
|
obj.Base = styleBaseThemeCircle
|
||||||
|
baseID := w.themeDefaultCircleStyleID
|
||||||
|
|
||||||
|
// class override from current theme (may be absent)
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if th := w.Theme(); th != nil {
|
||||||
|
if ov, ok := th.CircleClassOverride(obj.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeOverrides(classOv, o.Override)
|
||||||
|
obj.Override = o.Override // store only user override; class override comes from theme
|
||||||
|
obj.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.objects[id] = obj
|
||||||
|
w.indexDirty = true
|
||||||
|
w.rebuildIndexFromLastState()
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -226,16 +426,16 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, erro
|
|||||||
// AddLine validates and stores a line primitive in the world.
|
// AddLine validates and stores a line primitive in the world.
|
||||||
// The endpoints are given in real world units and are converted
|
// The endpoints are given in real world units and are converted
|
||||||
// to fixed-point before validation.
|
// to fixed-point before validation.
|
||||||
func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) {
|
func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) {
|
||||||
x1f := fixedPoint(x1)
|
x1f := fixedPoint(x1)
|
||||||
y1f := fixedPoint(y1)
|
y1f := fixedPoint(y1)
|
||||||
x2f := fixedPoint(x2)
|
x2f := fixedPoint(x2)
|
||||||
y2f := fixedPoint(y2)
|
y2f := fixedPoint(y2)
|
||||||
|
|
||||||
if ok := g.checkCoordinate(x1f, y1f); !ok {
|
if ok := w.checkCoordinate(x1f, y1f); !ok {
|
||||||
return 0, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
if ok := g.checkCoordinate(x2f, y2f); !ok {
|
if ok := w.checkCoordinate(x2f, y2f); !ok {
|
||||||
return 0, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,96 +445,119 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e
|
|||||||
opt(&o)
|
opt(&o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
styleID := g.resolveLineStyleID(o)
|
// styleID := g.resolveLineStyleID(o)
|
||||||
|
|
||||||
id, err := g.allocID()
|
id, err := w.allocID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
g.objects[id] = Line{
|
obj := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: x1f,
|
X1: x1f,
|
||||||
Y1: y1f,
|
Y1: y1f,
|
||||||
X2: x2f,
|
X2: x2f,
|
||||||
Y2: y2f,
|
Y2: y2f,
|
||||||
Priority: o.Priority,
|
Priority: o.Priority,
|
||||||
StyleID: styleID,
|
// StyleID: styleID,
|
||||||
HitSlopPx: o.HitSlopPx,
|
HitSlopPx: o.HitSlopPx,
|
||||||
}
|
}
|
||||||
|
|
||||||
g.indexDirty = true
|
obj.Class = o.Class
|
||||||
g.rebuildIndexFromLastState()
|
if o.hasStyleID {
|
||||||
|
obj.Base = styleBaseFixed
|
||||||
|
obj.StyleID = o.StyleID
|
||||||
|
obj.Override = StyleOverride{}
|
||||||
|
} else {
|
||||||
|
obj.Base = styleBaseThemeLine
|
||||||
|
baseID := w.themeDefaultLineStyleID
|
||||||
|
|
||||||
|
// class override from current theme (may be absent)
|
||||||
|
classOv := StyleOverride{}
|
||||||
|
if th := w.Theme(); th != nil {
|
||||||
|
if ov, ok := th.LineClassOverride(obj.Class); ok {
|
||||||
|
classOv = ov
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeOverrides(classOv, o.Override)
|
||||||
|
obj.Override = o.Override // store only user override; class override comes from theme
|
||||||
|
obj.StyleID = w.derivedStyleID(baseID, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.objects[id] = obj
|
||||||
|
w.indexDirty = true
|
||||||
|
w.rebuildIndexFromLastState()
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *World) resolvePointStyleID(o PointOptions) StyleID {
|
// func (g *World) resolvePointStyleID(o PointOptions) StyleID {
|
||||||
if o.hasStyleID {
|
// if o.hasStyleID {
|
||||||
return o.StyleID
|
// return o.StyleID
|
||||||
}
|
// }
|
||||||
if o.Override.IsZero() {
|
// if o.Override.IsZero() {
|
||||||
return StyleIDDefaultPoint
|
// return StyleIDDefaultPoint
|
||||||
}
|
// }
|
||||||
return g.styles.AddDerived(StyleIDDefaultPoint, o.Override)
|
// return g.styles.AddDerived(StyleIDDefaultPoint, o.Override)
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (g *World) resolveCircleStyleID(o CircleOptions) StyleID {
|
// func (g *World) resolveCircleStyleID(o CircleOptions) StyleID {
|
||||||
if o.hasStyleID {
|
// if o.hasStyleID {
|
||||||
return o.StyleID
|
// return o.StyleID
|
||||||
}
|
// }
|
||||||
if o.Override.IsZero() {
|
// if o.Override.IsZero() {
|
||||||
return StyleIDDefaultCircle
|
// return StyleIDDefaultCircle
|
||||||
}
|
// }
|
||||||
return g.styles.AddDerived(StyleIDDefaultCircle, o.Override)
|
// return g.styles.AddDerived(StyleIDDefaultCircle, o.Override)
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (g *World) resolveLineStyleID(o LineOptions) StyleID {
|
// func (g *World) resolveLineStyleID(o LineOptions) StyleID {
|
||||||
if o.hasStyleID {
|
// if o.hasStyleID {
|
||||||
return o.StyleID
|
// return o.StyleID
|
||||||
}
|
// }
|
||||||
if o.Override.IsZero() {
|
// if o.Override.IsZero() {
|
||||||
return StyleIDDefaultLine
|
// return StyleIDDefaultLine
|
||||||
}
|
// }
|
||||||
return g.styles.AddDerived(StyleIDDefaultLine, o.Override)
|
// return g.styles.AddDerived(StyleIDDefaultLine, o.Override)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
||||||
func (g *World) worldToCellX(x int) int {
|
func (w *World) worldToCellX(x int) int {
|
||||||
return worldToCell(x, g.W, g.cols, g.cellSize)
|
return worldToCell(x, w.W, w.cols, w.cellSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// worldToCellY converts a fixed-point Y coordinate to a grid row index.
|
// worldToCellY converts a fixed-point Y coordinate to a grid row index.
|
||||||
func (g *World) worldToCellY(y int) int {
|
func (w *World) worldToCellY(y int) int {
|
||||||
return worldToCell(y, g.H, g.rows, g.cellSize)
|
return worldToCell(y, w.H, w.rows, w.cellSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetGrid recreates the spatial grid with the given cell size
|
// resetGrid recreates the spatial grid with the given cell size
|
||||||
// and clears all previous indexing state.
|
// and clears all previous indexing state.
|
||||||
func (g *World) resetGrid(cellSize int) {
|
func (w *World) resetGrid(cellSize int) {
|
||||||
if cellSize <= 0 {
|
if cellSize <= 0 {
|
||||||
panic("resetGrid: invalid cell size")
|
panic("resetGrid: invalid cell size")
|
||||||
}
|
}
|
||||||
|
|
||||||
g.cellSize = cellSize
|
w.cellSize = cellSize
|
||||||
g.cols = ceilDiv(g.W, g.cellSize)
|
w.cols = ceilDiv(w.W, w.cellSize)
|
||||||
g.rows = ceilDiv(g.H, g.cellSize)
|
w.rows = ceilDiv(w.H, w.cellSize)
|
||||||
|
|
||||||
g.grid = make([][][]MapItem, g.rows)
|
w.grid = make([][][]MapItem, w.rows)
|
||||||
for row := range g.grid {
|
for row := range w.grid {
|
||||||
g.grid[row] = make([][]MapItem, g.cols)
|
w.grid[row] = make([][]MapItem, w.cols)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexObject inserts a single object into all grid cells touched by its
|
// indexObject inserts a single object into all grid cells touched by its
|
||||||
// indexing representation. Points are inserted into one cell, while circles
|
// indexing representation. Points are inserted into one cell, while circles
|
||||||
// and lines are inserted by their torus-aware bbox coverage.
|
// and lines are inserted by their torus-aware bbox coverage.
|
||||||
func (g *World) indexObject(o MapItem) {
|
func (w *World) indexObject(o MapItem) {
|
||||||
switch mapItem := o.(type) {
|
switch mapItem := o.(type) {
|
||||||
case Point:
|
case Point:
|
||||||
col := g.worldToCellX(mapItem.X)
|
col := w.worldToCellX(mapItem.X)
|
||||||
row := g.worldToCellY(mapItem.Y)
|
row := w.worldToCellY(mapItem.Y)
|
||||||
g.grid[row][col] = append(g.grid[row][col], mapItem)
|
w.grid[row][col] = append(w.grid[row][col], mapItem)
|
||||||
|
|
||||||
case Line:
|
case Line:
|
||||||
x1 := mapItem.X1
|
x1 := mapItem.X1
|
||||||
@@ -342,8 +565,8 @@ func (g *World) indexObject(o MapItem) {
|
|||||||
x2 := mapItem.X2
|
x2 := mapItem.X2
|
||||||
y2 := mapItem.Y2
|
y2 := mapItem.Y2
|
||||||
|
|
||||||
x1, x2 = shortestWrappedDelta(x1, x2, g.W)
|
x1, x2 = shortestWrappedDelta(x1, x2, w.W)
|
||||||
y1, y2 = shortestWrappedDelta(y1, y2, g.H)
|
y1, y2 = shortestWrappedDelta(y1, y2, w.H)
|
||||||
|
|
||||||
minX := min(x1, x2)
|
minX := min(x1, x2)
|
||||||
maxX := max(x1, x2)
|
maxX := max(x1, x2)
|
||||||
@@ -357,10 +580,14 @@ func (g *World) indexObject(o MapItem) {
|
|||||||
maxY++
|
maxY++
|
||||||
}
|
}
|
||||||
|
|
||||||
g.indexBBox(mapItem, minX, maxX, minY, maxY)
|
w.indexBBox(mapItem, minX, maxX, minY, maxY)
|
||||||
|
|
||||||
case Circle:
|
case Circle:
|
||||||
g.indexBBox(mapItem, mapItem.MinX(), mapItem.MaxX(), mapItem.MinY(), mapItem.MaxY())
|
rEff := circleRadiusEffFp(mapItem.Radius, w.circleRadiusScaleFp)
|
||||||
|
w.indexBBox(mapItem,
|
||||||
|
mapItem.X-rEff, mapItem.X+rEff,
|
||||||
|
mapItem.Y-rEff, mapItem.Y+rEff,
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
panic(fmt.Sprintf("indexing: unknown element %T", mapItem))
|
panic(fmt.Sprintf("indexing: unknown element %T", mapItem))
|
||||||
@@ -370,18 +597,18 @@ func (g *World) indexObject(o MapItem) {
|
|||||||
// indexBBox indexes an object by a half-open fixed-point bbox that may cross
|
// indexBBox indexes an object by a half-open fixed-point bbox that may cross
|
||||||
// torus boundaries. The bbox is split into wrapped in-world rectangles first,
|
// torus boundaries. The bbox is split into wrapped in-world rectangles first,
|
||||||
// then all covered grid cells are populated.
|
// then all covered grid cells are populated.
|
||||||
func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
|
func (w *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
|
||||||
rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY)
|
rects := splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
|
||||||
for _, r := range rects {
|
for _, r := range rects {
|
||||||
colStart := g.worldToCellX(r.minX)
|
colStart := w.worldToCellX(r.minX)
|
||||||
colEnd := g.worldToCellX(r.maxX - 1)
|
colEnd := w.worldToCellX(r.maxX - 1)
|
||||||
|
|
||||||
rowStart := g.worldToCellY(r.minY)
|
rowStart := w.worldToCellY(r.minY)
|
||||||
rowEnd := g.worldToCellY(r.maxY - 1)
|
rowEnd := w.worldToCellY(r.maxY - 1)
|
||||||
|
|
||||||
for col := colStart; col <= colEnd; col++ {
|
for col := colStart; col <= colEnd; col++ {
|
||||||
for row := rowStart; row <= rowEnd; row++ {
|
for row := rowStart; row <= rowEnd; row++ {
|
||||||
g.grid[row][col] = append(g.grid[row][col], o)
|
w.grid[row][col] = append(w.grid[row][col], o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,21 +616,21 @@ func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
|
|||||||
|
|
||||||
// IndexOnViewportChange is called when UI window sizes are changed.
|
// IndexOnViewportChange is called when UI window sizes are changed.
|
||||||
// cameraZoom is float64, converted inside world to fixed-point.
|
// cameraZoom is float64, converted inside world to fixed-point.
|
||||||
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
func (w *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
||||||
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
|
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
|
||||||
|
|
||||||
// Remember params for autonomous reindex after Add/Remove.
|
// Remember params for autonomous reindex after Add/Remove.
|
||||||
g.index.initialized = true
|
w.index.initialized = true
|
||||||
g.index.viewportW = viewportWidthPx
|
w.index.viewportW = viewportWidthPx
|
||||||
g.index.viewportH = viewportHeightPx
|
w.index.viewportH = viewportHeightPx
|
||||||
g.index.zoomFp = zoomFp
|
w.index.zoomFp = zoomFp
|
||||||
|
|
||||||
g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
|
w.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||||
g.indexDirty = false
|
w.indexDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
|
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
|
||||||
func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
|
func (w *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
|
||||||
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
|
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||||
|
|
||||||
cellsAcrossMin := 8
|
cellsAcrossMin := 8
|
||||||
@@ -412,9 +639,50 @@ func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx in
|
|||||||
cellSize := visibleMin / cellsAcrossMin
|
cellSize := visibleMin / cellsAcrossMin
|
||||||
cellSize = clamp(cellSize, cellSizeMin, cellSizeMax)
|
cellSize = clamp(cellSize, cellSizeMin, cellSizeMax)
|
||||||
|
|
||||||
g.resetGrid(cellSize)
|
w.resetGrid(cellSize)
|
||||||
|
|
||||||
for _, o := range g.objects {
|
for _, o := range w.objects {
|
||||||
g.indexObject(o)
|
w.indexObject(o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CircleRadiusScaleFp returns the current circle radius scale (fixed-point).
|
||||||
|
func (w *World) CircleRadiusScaleFp() int {
|
||||||
|
return w.circleRadiusScaleFp
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCircleRadiusScaleFp sets the circle radius scale (fixed-point).
|
||||||
|
// scaleFp must be > 0. This affects indexing, rendering and hit-testing,
|
||||||
|
// so it forces a full redraw and triggers reindex when possible.
|
||||||
|
func (w *World) SetCircleRadiusScaleFp(scaleFp int) error {
|
||||||
|
if scaleFp <= 0 {
|
||||||
|
return errors.New("invalid circle radius scale")
|
||||||
|
}
|
||||||
|
if scaleFp == w.circleRadiusScaleFp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.circleRadiusScaleFp = scaleFp
|
||||||
|
|
||||||
|
// Radius scale affects circle bbox => spatial index must be rebuilt.
|
||||||
|
w.indexDirty = true
|
||||||
|
w.rebuildIndexFromLastState()
|
||||||
|
|
||||||
|
// Visual change => full redraw.
|
||||||
|
w.ForceFullRedrawNext()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// circleRadiusEffFp converts a raw circle radius (world-fixed) into effective radius (world-fixed)
|
||||||
|
// using g.circleRadiusScaleFp.
|
||||||
|
func circleRadiusEffFp(rawRadiusFp, circleRadiusScaleFp int) int {
|
||||||
|
// Use int64 to avoid overflow.
|
||||||
|
v := (int64(rawRadiusFp) * int64(circleRadiusScaleFp)) / int64(SCALE)
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > int64(^uint(0)>>1) {
|
||||||
|
// Defensive; should never happen with sane inputs on 64-bit.
|
||||||
|
return int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
func newIndexedTestWorld() *World {
|
func newIndexedTestWorld() *World {
|
||||||
w := NewWorld(10, 10)
|
w := NewWorld(10, 10)
|
||||||
|
w.SetCircleRadiusScaleFp(SCALE)
|
||||||
w.resetGrid(2 * SCALE) // 5x5 grid.
|
w.resetGrid(2 * SCALE) // 5x5 grid.
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func correctCameraZoomFp(
|
|||||||
//
|
//
|
||||||
// currentZoom is the user-facing zoom multiplier in floating-point form.
|
// currentZoom is the user-facing zoom multiplier in floating-point form.
|
||||||
// The result is returned in the same representation.
|
// The result is returned in the same representation.
|
||||||
func (g *World) CorrectCameraZoom(
|
func (w *World) CorrectCameraZoom(
|
||||||
currentZoom float64,
|
currentZoom float64,
|
||||||
viewportWidthPx int,
|
viewportWidthPx int,
|
||||||
viewportHeightPx int,
|
viewportHeightPx int,
|
||||||
@@ -110,8 +110,8 @@ func (g *World) CorrectCameraZoom(
|
|||||||
currentZoomFp,
|
currentZoomFp,
|
||||||
viewportWidthPx,
|
viewportWidthPx,
|
||||||
viewportHeightPx,
|
viewportHeightPx,
|
||||||
g.W,
|
w.W,
|
||||||
g.H,
|
w.H,
|
||||||
MIN_ZOOM,
|
MIN_ZOOM,
|
||||||
MAX_ZOOM,
|
MAX_ZOOM,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user