themes and styles

This commit is contained in:
IliaDenisov
2026-03-08 15:31:17 +02:00
parent e37a67bc99
commit 1c2fc30127
39 changed files with 2693 additions and 199 deletions
+13 -13
View File
@@ -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)
} }
+50
View File
@@ -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")
}
+29
View File
@@ -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
)
+29
View File
@@ -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()
}
+9
View File
@@ -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
View File
@@ -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)
+151
View File
@@ -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, &params, cx, cy)
require.NoError(t, err)
if !tt.wantHit {
require.Empty(t, hits)
return
}
require.NotEmpty(t, hits)
require.Equal(t, tt.wantKind, hits[0].Kind)
})
}
}
+9 -9
View File
@@ -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
+36
View File
@@ -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, &params, 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.
}
+3 -1
View File
@@ -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 {
+18
View File
@@ -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
+24
View File
@@ -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".
+12 -3
View File
@@ -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)
} }
+185
View File
@@ -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"))
}
+122
View File
@@ -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"))
}
+17 -16
View File
@@ -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 {
+4 -3
View File
@@ -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)
+2 -1
View File
@@ -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))
+8 -1
View File
@@ -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{
+4 -3
View File
@@ -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()
+1 -1
View File
@@ -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")
+1
View File
@@ -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)
}
+27
View File
@@ -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)
}
+84
View File
@@ -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()
}
+29
View File
@@ -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
}
+93
View File
@@ -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
}
+120
View File
@@ -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)
}
+372
View File
@@ -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
}
+149
View File
@@ -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
}
+144
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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
} }
+3 -3
View File
@@ -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,
) )