From 1c2fc30127720758d6e02cd4604e62af762b70c0 Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Sun, 8 Mar 2026 15:31:17 +0200 Subject: [PATCH] themes and styles --- client/editor.go | 36 +- client/world/circle_radius_scale_test.go | 50 ++ client/world/classes.go | 29 + client/world/drawer.go | 29 + client/world/fake_drawer_test.go | 9 + client/world/hit.go | 14 +- client/world/hit_circle_strict_test.go | 151 +++++ client/world/hit_primitives.go | 18 +- client/world/hit_test.go | 36 ++ client/world/indexing_test.go | 4 +- client/world/options.go | 18 + client/world/primitive.go | 24 + client/world/renderer.go | 15 +- client/world/renderer_background.go | 185 ++++++ .../renderer_background_offset_scale_test.go | 151 +++++ .../world/renderer_background_offset_test.go | 95 ++++ .../world/renderer_background_scale_test.go | 95 ++++ client/world/renderer_background_test.go | 122 ++++ client/world/renderer_circles.go | 33 +- client/world/renderer_circles_test.go | 7 +- client/world/renderer_circles_wrap_test.go | 3 +- client/world/renderer_draw.go | 9 +- client/world/renderer_draw_primitives.go | 7 +- .../renderer_smoke_all_primitives_test.go | 2 +- client/world/renderer_smoke_mixed_test.go | 2 +- client/world/renderer_test.go | 1 + client/world/renderer_theme_hotswap_test.go | 90 +++ client/world/style.go | 27 + client/world/style_cache_test.go | 84 +++ client/world/style_override_fingerprint.go | 96 ++++ client/world/style_override_merge.go | 29 + client/world/theme.go | 93 +++ client/world/theme_classes_test.go | 120 ++++ client/world/theme_default.go | 372 ++++++++++++ client/world/theme_override_test.go | 149 +++++ client/world/theme_test.go | 144 +++++ client/world/world.go | 536 +++++++++++++----- client/world/world_test.go | 1 + client/world/zoom.go | 6 +- 39 files changed, 2693 insertions(+), 199 deletions(-) create mode 100644 client/world/circle_radius_scale_test.go create mode 100644 client/world/classes.go create mode 100644 client/world/hit_circle_strict_test.go create mode 100644 client/world/renderer_background.go create mode 100644 client/world/renderer_background_offset_scale_test.go create mode 100644 client/world/renderer_background_offset_test.go create mode 100644 client/world/renderer_background_scale_test.go create mode 100644 client/world/renderer_background_test.go create mode 100644 client/world/renderer_theme_hotswap_test.go create mode 100644 client/world/style_cache_test.go create mode 100644 client/world/style_override_fingerprint.go create mode 100644 client/world/style_override_merge.go create mode 100644 client/world/theme.go create mode 100644 client/world/theme_classes_test.go create mode 100644 client/world/theme_default.go create mode 100644 client/world/theme_override_test.go create mode 100644 client/world/theme_test.go diff --git a/client/editor.go b/client/editor.go index 6708f7f..aefada2 100644 --- a/client/editor.go +++ b/client/editor.go @@ -245,11 +245,21 @@ func (e *editor) BuildUI(w fyne.Window) { } func (e *editor) loadWorld(w *world.World) { - e.world = w - // TODO: store camera position in user settings - e.wp.CameraXWorldFp = w.W / 2 - e.wp.CameraYWorldFp = w.H / 2 - e.updateSizes() + if e.world == nil { + w.SetCircleRadiusScaleFp(world.SCALE / 4) + e.world = w + // TODO: store camera position in user settings + e.wp.CameraXWorldFp = w.W / 2 + e.wp.CameraYWorldFp = w.H / 2 + e.world.SetTheme(world.ThemeDark) + e.updateSizes() + } else { + if e.world.Theme().ID() == "theme.light.v1" { + e.world.SetTheme(world.ThemeDark) + } else { + e.world.SetTheme(world.ThemeLight) + } + } e.RequestRefresh() } @@ -304,24 +314,14 @@ func mockWorldInit(w *world.World) { StrokeDashes: new([]float64{10.}), }) - discStyle := w.AddStyleCircle(world.StyleOverride{ - 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 { + if _, err := w.AddCircle(150, 150, 50); err != nil { 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) } - if _, err := w.AddCircle(299, 150, 30, world.CircleWithStyleID(circleStyle)); err != nil { + if _, err := w.AddCircle(299, 150, 30); err != nil { panic(err) } diff --git a/client/world/circle_radius_scale_test.go b/client/world/circle_radius_scale_test.go new file mode 100644 index 0000000..12160d5 --- /dev/null +++ b/client/world/circle_radius_scale_test.go @@ -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") +} diff --git a/client/world/classes.go b/client/world/classes.go new file mode 100644 index 0000000..d73c079 --- /dev/null +++ b/client/world/classes.go @@ -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 +) diff --git a/client/world/drawer.go b/client/world/drawer.go index 3973de3..07bd614 100644 --- a/client/world/drawer.go +++ b/client/world/drawer.go @@ -74,6 +74,10 @@ type PrimitiveDrawer interface { // Clear operations must NOT change clip state. ClearAllTo(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. @@ -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() +} diff --git a/client/world/fake_drawer_test.go b/client/world/fake_drawer_test.go index 90bdd70..c992182 100644 --- a/client/world/fake_drawer_test.go +++ b/client/world/fake_drawer_test.go @@ -2,6 +2,7 @@ package world import ( "fmt" + "image" "image/color" ) @@ -239,3 +240,11 @@ func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) { func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) { d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h)) } + +func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) { + d.snapshotCommand("DrawImage", float64(x), float64(y)) +} + +func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) { + d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h)) +} diff --git a/client/world/hit.go b/client/world/hit.go index bb0ca0f..b612125 100644 --- a/client/world/hit.go +++ b/client/world/hit.go @@ -127,7 +127,7 @@ func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx in } // Gather candidates from grid cells, dedupe by ID. - cand := make(map[PrimitiveID]MapItem, 32) + cand := make(map[PrimitiveID]struct{}, 32) for _, r := range rects { colStart := w.worldToCellX(r.minX) 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++ { cell := w.grid[row][col] 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] limit := cap(out) - for _, it := range cand { - h, ok := w.hitOne(it, cursorX, cursorY, zoomFp, allowWrap) + for id := range cand { + cur, ok := w.objects[id] + if !ok { + continue + } + h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap) if !ok { 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. 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: return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H) diff --git a/client/world/hit_circle_strict_test.go b/client/world/hit_circle_strict_test.go new file mode 100644 index 0000000..6849572 --- /dev/null +++ b/client/world/hit_circle_strict_test.go @@ -0,0 +1,151 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) { + t.Parallel() + + type tc struct { + name string + fillVisible bool + rawRadius int // world units (not fixed); zoom=1 => 1px per unit + scaleFp int + hitSlopPx int + cursorDxPx int // offset from center in pixels along X axis + wantHit bool + wantKind PrimitiveKind + } + + // Common settings: world 20x20, viewport 200x200, camera at center (10,10). + params := RenderParams{ + ViewportWidthPx: 200, + ViewportHeightPx: 200, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 10 * SCALE, + CameraYWorldFp: 10 * SCALE, + CameraZoom: 1.0, + } + + tests := []tc{ + { + name: "filled: on boundary hits (R=4, S=1, dx=4)", + fillVisible: true, + rawRadius: 2, + scaleFp: 2 * SCALE, // eff radius = 4 + hitSlopPx: 1, + cursorDxPx: 4, + wantHit: true, + wantKind: KindCircle, + }, + { + name: "filled: outside beyond slop misses (R=4, S=1, dx=6)", + fillVisible: true, + rawRadius: 2, + scaleFp: 2 * SCALE, + hitSlopPx: 1, + cursorDxPx: 6, // 6 > R+S = 5 + wantHit: false, + }, + { + name: "filled: just inside slop hits (R=4, S=1, dx=5)", + fillVisible: true, + rawRadius: 2, + scaleFp: 2 * SCALE, + hitSlopPx: 1, + cursorDxPx: 5, // == R+S + wantHit: true, + wantKind: KindCircle, + }, + { + name: "stroke-only: center must miss even if slop would cover", + fillVisible: false, + rawRadius: 2, + scaleFp: 2 * SCALE, // eff radius = 4 + hitSlopPx: 10, // huge, would normally include center without our rule + cursorDxPx: 0, + wantHit: false, + }, + { + name: "stroke-only: on ring hits (R=4, S=1, dx=4)", + fillVisible: false, + rawRadius: 2, + scaleFp: 2 * SCALE, + hitSlopPx: 1, + cursorDxPx: 4, + wantHit: true, + wantKind: KindCircle, + }, + { + name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)", + fillVisible: false, + rawRadius: 2, + scaleFp: 2 * SCALE, + hitSlopPx: 1, + cursorDxPx: 2, // 2 < R-S = 3 + wantHit: false, + }, + { + name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)", + fillVisible: false, + rawRadius: 2, + scaleFp: 2 * SCALE, + hitSlopPx: 1, + cursorDxPx: 6, // 6 > R+S = 5 + wantHit: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w := NewWorld(20, 20) + w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) + + require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp)) + + // Build a stroke-only circle style if needed. + var opts []CircleOpt + opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx)) + + if !tt.fillVisible { + // Force fill alpha=0 => stroke-only for hit-test and rendering. + sw := 1.0 + styleID := w.AddStyleCircle(StyleOverride{ + FillColor: color.RGBA{A: 0}, + StrokeColor: color.RGBA{A: 255}, + StrokeWidthPx: &sw, + }) + opts = append(opts, CircleWithStyleID(styleID)) + } + + _, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...) + require.NoError(t, err) + + w.Reindex() + + // Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit. + cx := params.ViewportWidthPx/2 + tt.cursorDxPx + cy := params.ViewportHeightPx / 2 + + buf := make([]Hit, 0, 8) + hits, err := w.HitTest(buf, ¶ms, cx, cy) + require.NoError(t, err) + + if !tt.wantHit { + require.Empty(t, hits) + return + } + + require.NotEmpty(t, hits) + require.Equal(t, tt.wantKind, hits[0].Kind) + }) + } +} diff --git a/client/world/hit_primitives.go b/client/world/hit_primitives.go index f38294d..3f91e90 100644 --- a/client/world/hit_primitives.go +++ b/client/world/hit_primitives.go @@ -51,7 +51,7 @@ func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH in 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) 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. // IMPORTANT: point-like disc behavior applies only for filled circles. - rPx := worldSpanFixedToCanvasPx(c.Radius, zoomFp) + rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp) pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx 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. minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) effR := minRW - if c.Radius > effR { - effR = c.Radius + if effRadiusFp > effR { + effR = effRadiusFp } r := effR + slopW 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, X: c.X, Y: c.Y, - Radius: c.Radius, + Radius: effRadiusFp, }, true } 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). if fillVisible { - r := c.Radius + slopW + r := effRadiusFp + slopW if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 { return Hit{ ID: c.Id, @@ -109,7 +109,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo DistanceSq: ds, X: c.X, Y: c.Y, - Radius: c.Radius, + Radius: effRadiusFp, }, true } 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. // For very small circles, expand the effective radius to a minimum visible size // so that ring selection remains practical, while still excluding center. - effR := c.Radius + effR := effRadiusFp if rPx < CirclePointLikeMinRadiusPx { minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) if minRW > effR { @@ -145,7 +145,7 @@ func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, wo DistanceSq: ds, X: c.X, Y: c.Y, - Radius: c.Radius, + Radius: effRadiusFp, }, true } return Hit{}, false diff --git a/client/world/hit_test.go b/client/world/hit_test.go index 7b33c0c..b3fa30d 100644 --- a/client/world/hit_test.go +++ b/client/world/hit_test.go @@ -150,3 +150,39 @@ func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) { require.NotEmpty(t, hits) require.Equal(t, KindCircle, hits[0].Kind) } + +func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults + w.IndexOnViewportChange(100, 100, 1.0) + + // raw radius=2 units, centered at (5,5) + _, err := w.AddCircle(5, 5, 2) + require.NoError(t, err) + + // scale=2 => eff radius=4 + require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE)) + w.Reindex() + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + // Tap at +4 px from center should hit (eff radius 4). + buf := make([]Hit, 0, 8) + hits, err := w.HitTest(buf, ¶ms, 50+4, 50) + require.NoError(t, err) + require.NotEmpty(t, hits) + require.Equal(t, KindCircle, hits[0].Kind) + + // Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options. + // We'll add a small-slope circle and test deterministically. +} diff --git a/client/world/indexing_test.go b/client/world/indexing_test.go index 5dd7365..176de9e 100644 --- a/client/world/indexing_test.go +++ b/client/world/indexing_test.go @@ -13,7 +13,9 @@ type gridCell struct { } 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 { diff --git a/client/world/options.go b/client/world/options.go index c130fe9..2b79220 100644 --- a/client/world/options.go +++ b/client/world/options.go @@ -9,6 +9,7 @@ type PointOptions struct { Priority int StyleID StyleID Override StyleOverride + Class PointClassID HitSlopPx int @@ -19,6 +20,7 @@ func defaultPointOptions() PointOptions { return PointOptions{ Priority: DefaultPriorityPoint, 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. // If you also set StyleID, StyleID wins. func PointWithStyleOverride(ov StyleOverride) PointOpt { @@ -56,6 +62,7 @@ type CircleOptions struct { Priority int StyleID StyleID Override StyleOverride + Class CircleClassID HitSlopPx int @@ -66,6 +73,7 @@ func defaultCircleOptions() CircleOptions { return CircleOptions{ Priority: DefaultPriorityCircle, 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 { return func(o *CircleOptions) { o.Override = ov @@ -99,6 +111,7 @@ type LineOptions struct { Priority int StyleID StyleID Override StyleOverride + Class LineClassID HitSlopPx int @@ -109,6 +122,7 @@ func defaultLineOptions() LineOptions { return LineOptions{ Priority: DefaultPriorityLine, 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 { return func(o *LineOptions) { o.Override = ov diff --git a/client/world/primitive.go b/client/world/primitive.go index df70cba..b3e3ff6 100644 --- a/client/world/primitive.go +++ b/client/world/primitive.go @@ -9,6 +9,15 @@ type MapItem interface { ID() PrimitiveID } +type styleBase uint8 + +const ( + styleBaseFixed styleBase = iota + styleBaseThemeLine + styleBaseThemeCircle + styleBaseThemePoint +) + // Point is a point primitive in fixed-point world coordinates. type Point struct { Id PrimitiveID @@ -18,6 +27,11 @@ type Point struct { Priority int // StyleID references a resolved style in the world's style table. StyleID StyleID + // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. + Base styleBase + // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). + Override StyleOverride + Class PointClassID // HitSlopPx expands hit-test radius in screen pixels (per-object override). // 0 means "use primitive default". @@ -34,6 +48,11 @@ type Line struct { Priority int // StyleID references a resolved style in the world's style table. StyleID StyleID + // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. + Base styleBase + // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). + Override StyleOverride + Class LineClassID // HitSlopPx expands hit-test radius in screen pixels (per-object override). // 0 means "use primitive default". @@ -50,6 +69,11 @@ type Circle struct { Priority int // StyleID references a resolved style in the world's style table. StyleID StyleID + // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. + Base styleBase + // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). + Override StyleOverride + Class CircleClassID // HitSlopPx expands hit-test radius in screen pixels (per-object override). // 0 means "use primitive default". diff --git a/client/world/renderer.go b/client/world/renderer.go index fa9252f..dc95a47 100644 --- a/client/world/renderer.go +++ b/client/world/renderer.go @@ -171,15 +171,23 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { 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 v, ok := params.Options.BackgroundColor.(color.RGBA); !ok { panic("Options.BackgroundColor is not color.RGBA type") } else { bg = v } + } else { + tc := w.Theme().BackgroundColor() + if alphaNonZero(tc) { + bg = tc + } } + allowWrap := params.Options == nil || !params.Options.DisableWrapScroll + defer func() { if !params.Debug { return @@ -217,8 +225,6 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { policy = *params.Options.Incremental } - allowWrap := params.Options == nil || !params.Options.DisableWrapScroll - // --- Try incremental path first when state is initialized and geometry matches --- dxPx, dyPx, derr := w.ComputePanShiftPx(params) if derr == nil { @@ -243,6 +249,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { if len(toDraw) > 0 { for _, r := range toDraw { drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg) + w.drawBackground(drawer, params, r) } plan, err := w.buildRenderPlanStageA(params) @@ -295,6 +302,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { for _, r := range dirtyToDraw { 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. @@ -328,6 +336,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { } drawer.ClearAllTo(bg) + w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()}) w.drawPlanSinglePass(drawer, plan, allowWrap) return w.CommitFullRedrawState(params) } diff --git a/client/world/renderer_background.go b/client/world/renderer_background.go new file mode 100644 index 0000000..7bb0200 --- /dev/null +++ b/client/world/renderer_background.go @@ -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 +} diff --git a/client/world/renderer_background_offset_scale_test.go b/client/world/renderer_background_offset_scale_test.go new file mode 100644 index 0000000..f698a33 --- /dev/null +++ b/client/world/renderer_background_offset_scale_test.go @@ -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 +} diff --git a/client/world/renderer_background_offset_test.go b/client/world/renderer_background_offset_test.go new file mode 100644 index 0000000..856bfb5 --- /dev/null +++ b/client/world/renderer_background_offset_test.go @@ -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")) +} diff --git a/client/world/renderer_background_scale_test.go b/client/world/renderer_background_scale_test.go new file mode 100644 index 0000000..1a6d796 --- /dev/null +++ b/client/world/renderer_background_scale_test.go @@ -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")) +} diff --git a/client/world/renderer_background_test.go b/client/world/renderer_background_test.go new file mode 100644 index 0000000..0ea291f --- /dev/null +++ b/client/world/renderer_background_test.go @@ -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")) +} diff --git a/client/world/renderer_circles.go b/client/world/renderer_circles.go index 92c425a..2eaf0e7 100644 --- a/client/world/renderer_circles.go +++ b/client/world/renderer_circles.go @@ -1,7 +1,7 @@ package world // drawCirclesFromPlan executes a circles-only draw from an already built render plan. -func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool) { +func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool, circleRadiusScaleFp int) { for _, td := range plan.Tiles { if td.ClipW <= 0 || td.ClipH <= 0 { continue @@ -30,13 +30,14 @@ func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH for _, c := range circles { var shifts []wrapShift + effRadius := circleRadiusEffFp(c.Radius, circleRadiusScaleFp) if allowWrap { - shifts = circleWrapShifts(c, worldW, worldH) + shifts = circleWrapShifts(c.X, c.Y, effRadius, worldW, worldH) } else { shifts = []wrapShift{{dx: 0, dy: 0}} } 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}) } } @@ -75,27 +76,27 @@ type wrapShift struct { // circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render // all torus copies of the circle inside the canonical world domain. // 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. // (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}} } xShifts := []int{0} yShifts := []int{0} - if c.X+c.Radius >= worldW { + if cx+radiusFp >= worldW { xShifts = append(xShifts, -worldW) } - if c.X-c.Radius < 0 { + if cx-radiusFp < 0 { xShifts = append(xShifts, worldW) } - if c.Y+c.Radius >= worldH { + if cy+radiusFp >= worldH { yShifts = append(yShifts, -worldH) } - if c.Y-c.Radius < 0 { + if cy-radiusFp < 0 { 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. // 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. segMinX := tile.OffsetX + tile.Rect.minX 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 // Circle bbox in the same unwrapped space (apply shift + tile offset). - cx := c.X + tile.OffsetX + dx - cy := c.Y + tile.OffsetY + dy + cx = cx + tile.OffsetX + dx + cy = cy + tile.OffsetY + dy - minX := cx - c.Radius - maxX := cx + c.Radius - minY := cy - c.Radius - maxY := cy + c.Radius + minX := cx - radiusFp + maxX := cx + radiusFp + minY := cy - radiusFp + maxY := cy + radiusFp // Treat bbox as half-open for intersection checks. if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY { diff --git a/client/world/renderer_circles_test.go b/client/world/renderer_circles_test.go index fe7fb98..faa9899 100644 --- a/client/world/renderer_circles_test.go +++ b/client/world/renderer_circles_test.go @@ -37,7 +37,7 @@ func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { require.NoError(t, err) 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. wantNames := []string{ @@ -119,7 +119,7 @@ func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) { require.NoError(t, err) d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w.W, w.H, true) + drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) // No circles => no commands. require.Empty(t, d.Commands()) @@ -150,7 +150,7 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) { require.NoError(t, err) 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. cmds := d.CommandsByName("AddCircle") @@ -167,6 +167,7 @@ func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) { t.Parallel() w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) _, err := w.AddCircle(9, 9, 2) diff --git a/client/world/renderer_circles_wrap_test.go b/client/world/renderer_circles_wrap_test.go index 2cb997e..9c7a060 100644 --- a/client/world/renderer_circles_wrap_test.go +++ b/client/world/renderer_circles_wrap_test.go @@ -11,6 +11,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi // World 10x10 units => 10px at zoom=1 when viewport==world. w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) type tc struct { @@ -74,7 +75,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi require.NoError(t, err) d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w2.W, w2.H, true) + drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp) cmds := d.CommandsByName("AddCircle") require.Len(t, cmds, len(tt.wantCenters)) diff --git a/client/world/renderer_draw.go b/client/world/renderer_draw.go index 5e61d97..d82dc12 100644 --- a/client/world/renderer_draw.go +++ b/client/world/renderer_draw.go @@ -74,7 +74,14 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo items := make([]drawItem, 0, len(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: vv := v items = append(items, drawItem{ diff --git a/client/world/renderer_draw_primitives.go b/client/world/renderer_draw_primitives.go index 7cd3660..9aff47e 100644 --- a/client/world/renderer_draw_primitives.go +++ b/client/world/renderer_draw_primitives.go @@ -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) { var shifts []wrapShift + effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp) if allowWrap { - shifts = circleWrapShifts(c, w.W, w.H) + shifts = circleWrapShifts(c.X, c.Y, effRadius, w.W, w.H) } else { shifts = []wrapShift{{dx: 0, dy: 0}} } - rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp) + rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp) 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 } diff --git a/client/world/renderer_smoke_all_primitives_test.go b/client/world/renderer_smoke_all_primitives_test.go index 8d6c6d6..64cfc05 100644 --- a/client/world/renderer_smoke_all_primitives_test.go +++ b/client/world/renderer_smoke_all_primitives_test.go @@ -45,7 +45,7 @@ func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) { // Execute all three passes over the same plan. 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) names := d.CommandNames() diff --git a/client/world/renderer_smoke_mixed_test.go b/client/world/renderer_smoke_mixed_test.go index bfe7473..12b89ae 100644 --- a/client/world/renderer_smoke_mixed_test.go +++ b/client/world/renderer_smoke_mixed_test.go @@ -36,7 +36,7 @@ func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) { d := &fakePrimitiveDrawer{} 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() require.Contains(t, names, "AddPoint") diff --git a/client/world/renderer_test.go b/client/world/renderer_test.go index 650cbdd..b967f3e 100644 --- a/client/world/renderer_test.go +++ b/client/world/renderer_test.go @@ -471,6 +471,7 @@ func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing t.Parallel() w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) // Circle near the left edge crossing X=0 boundary. diff --git a/client/world/renderer_theme_hotswap_test.go b/client/world/renderer_theme_hotswap_test.go new file mode 100644 index 0000000..6b03cf6 --- /dev/null +++ b/client/world/renderer_theme_hotswap_test.go @@ -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) +} diff --git a/client/world/style.go b/client/world/style.go index cb7c7f2..aeb2b14 100644 --- a/client/world/style.go +++ b/client/world/style.go @@ -205,3 +205,30 @@ func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID t.styles[id] = derived 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) +} diff --git a/client/world/style_cache_test.go b/client/world/style_cache_test.go new file mode 100644 index 0000000..c1c1788 --- /dev/null +++ b/client/world/style_cache_test.go @@ -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) +} diff --git a/client/world/style_override_fingerprint.go b/client/world/style_override_fingerprint.go new file mode 100644 index 0000000..da3ce04 --- /dev/null +++ b/client/world/style_override_fingerprint.go @@ -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() +} diff --git a/client/world/style_override_merge.go b/client/world/style_override_merge.go new file mode 100644 index 0000000..440f2c5 --- /dev/null +++ b/client/world/style_override_merge.go @@ -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 +} diff --git a/client/world/theme.go b/client/world/theme.go new file mode 100644 index 0000000..c892b4e --- /dev/null +++ b/client/world/theme.go @@ -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 +} diff --git a/client/world/theme_classes_test.go b/client/world/theme_classes_test.go new file mode 100644 index 0000000..5ef67ac --- /dev/null +++ b/client/world/theme_classes_test.go @@ -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) +} diff --git a/client/world/theme_default.go b/client/world/theme_default.go new file mode 100644 index 0000000..f3d8399 --- /dev/null +++ b/client/world/theme_default.go @@ -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 +} diff --git a/client/world/theme_override_test.go b/client/world/theme_override_test.go new file mode 100644 index 0000000..cde6bee --- /dev/null +++ b/client/world/theme_override_test.go @@ -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 +} diff --git a/client/world/theme_test.go b/client/world/theme_test.go new file mode 100644 index 0000000..c9b5722 --- /dev/null +++ b/client/world/theme_test.go @@ -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")) +} diff --git a/client/world/world.go b/client/world/world.go index 5aff9bb..ed5f114 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -18,6 +18,10 @@ type indexState struct { viewportH int zoomFp int } +type derivedStyleKey struct { + base StyleID + fp uint64 +} // World stores torus world dimensions, all registered objects, // and the grid-based spatial index built for the current viewport settings. @@ -27,7 +31,14 @@ type World struct { cellSize int rows, cols int 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. nextID PrimitiveID @@ -37,7 +48,8 @@ type World struct { indexDirty bool index indexState - renderState rendererIncrementalState + renderState rendererIncrementalState + derivedCache map[derivedStyleKey]StyleID } // NewWorld constructs a new world with the given real dimensions. @@ -52,103 +64,247 @@ func NewWorld(width, height int) *World { cellSize: 1, objects: make(map[PrimitiveID]MapItem), styles: NewStyleTable(), - nextID: 1, // 0 is reserved as "invalid" + 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" } } // 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. -func (g *World) allocID() (PrimitiveID, error) { - if n := len(g.freeIDs); n > 0 { - id := g.freeIDs[n-1] - g.freeIDs = g.freeIDs[:n-1] +func (w *World) allocID() (PrimitiveID, error) { + if n := len(w.freeIDs); n > 0 { + id := w.freeIDs[n-1] + w.freeIDs = w.freeIDs[:n-1] return id, nil } - if g.nextID == PrimitiveID(^uint32(0)) { + if w.nextID == PrimitiveID(^uint32(0)) { return 0, errIDExhausted } - id := g.nextID - g.nextID++ + id := w.nextID + w.nextID++ return id, nil } // 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 { return } - g.freeIDs = append(g.freeIDs, id) + w.freeIDs = append(w.freeIDs, id) } // checkCoordinate reports whether the fixed-point coordinate (xf, yf) // lies inside the world bounds: [0, W) x [0, H). -func (g *World) checkCoordinate(xf, yf int) bool { - if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H { +func (w *World) checkCoordinate(xf, yf int) bool { + if xf < 0 || xf >= w.W || yf < 0 || yf >= w.H { return false } return true } // AddStyleLine creates a new line style derived from the default line style. -func (g *World) AddStyleLine(override StyleOverride) StyleID { - return g.styles.AddDerived(StyleIDDefaultLine, override) +func (w *World) AddStyleLine(override StyleOverride) StyleID { + return w.styles.AddDerived(StyleIDDefaultLine, override) } // AddStyleCircle creates a new circle style derived from the default circle style. -func (g *World) AddStyleCircle(override StyleOverride) StyleID { - return g.styles.AddDerived(StyleIDDefaultCircle, override) +func (w *World) AddStyleCircle(override StyleOverride) StyleID { + return w.styles.AddDerived(StyleIDDefaultCircle, override) } // AddStylePoint creates a new point style derived from the default point style. -func (g *World) AddStylePoint(override StyleOverride) StyleID { - return g.styles.AddDerived(StyleIDDefaultPoint, override) +func (w *World) AddStylePoint(override StyleOverride) StyleID { + 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. // It marks the spatial index dirty and triggers an autonomous rebuild if possible. -func (g *World) Remove(id PrimitiveID) error { - if _, ok := g.objects[id]; !ok { +func (w *World) Remove(id PrimitiveID) error { + if _, ok := w.objects[id]; !ok { return errNoSuchObject } - delete(g.objects, id) - g.freeID(id) + delete(w.objects, id) + w.freeID(id) - g.indexDirty = true - g.rebuildIndexFromLastState() + w.indexDirty = true + w.rebuildIndexFromLastState() return nil } // 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. -func (g *World) Reindex() { - g.indexDirty = true - g.rebuildIndexFromLastState() +func (w *World) Reindex() { + w.indexDirty = true + w.rebuildIndexFromLastState() } // rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp // from renderer state. If that state is not initialized, it does nothing. -func (g *World) rebuildIndexFromLastState() { - if !g.indexDirty { +func (w *World) rebuildIndexFromLastState() { + if !w.indexDirty { return } - if !g.index.initialized { + if !w.index.initialized { 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 } - g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp) - g.indexDirty = false + w.indexOnViewportChangeZoomFp(w.index.viewportW, w.index.viewportH, w.index.zoomFp) + w.indexDirty = false } // AddPoint validates and stores a point primitive in the world. // The input coordinates are given in real world units and are converted // 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) yf := fixedPoint(y) - if ok := g.checkCoordinate(xf, yf); !ok { + if ok := w.checkCoordinate(xf, yf); !ok { return 0, errBadCoordinate } @@ -158,24 +314,47 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) { opt(&o) } } - styleID := g.resolvePointStyleID(o) + // styleID := g.resolvePointStyleID(o) - id, err := g.allocID() + id, err := w.allocID() if err != nil { return 0, err } - g.objects[id] = Point{ - Id: id, - X: xf, - Y: yf, - Priority: o.Priority, - StyleID: styleID, + obj := Point{ + Id: id, + X: xf, + Y: yf, + Priority: o.Priority, + // StyleID: styleID, HitSlopPx: o.HitSlopPx, } - g.indexDirty = true - g.rebuildIndexFromLastState() + obj.Class = o.Class + 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 } @@ -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. // The center and radius are given in real world units and are converted // 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) yf := fixedPoint(y) - if ok := g.checkCoordinate(xf, yf); !ok { + if ok := w.checkCoordinate(xf, yf); !ok { return 0, errBadCoordinate } if r < 0 { @@ -200,25 +379,46 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, erro opt(&o) } } - styleID := g.resolveCircleStyleID(o) - id, err := g.allocID() + id, err := w.allocID() if err != nil { return 0, err } - g.objects[id] = Circle{ + obj := Circle{ Id: id, X: xf, Y: yf, Radius: fixedPoint(r), Priority: o.Priority, - StyleID: styleID, HitSlopPx: o.HitSlopPx, } - g.indexDirty = true - g.rebuildIndexFromLastState() + obj.Class = o.Class + 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 } @@ -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. // The endpoints are given in real world units and are converted // 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) y1f := fixedPoint(y1) x2f := fixedPoint(x2) y2f := fixedPoint(y2) - if ok := g.checkCoordinate(x1f, y1f); !ok { + if ok := w.checkCoordinate(x1f, y1f); !ok { return 0, errBadCoordinate } - if ok := g.checkCoordinate(x2f, y2f); !ok { + if ok := w.checkCoordinate(x2f, y2f); !ok { return 0, errBadCoordinate } @@ -245,96 +445,119 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e opt(&o) } } - styleID := g.resolveLineStyleID(o) + // styleID := g.resolveLineStyleID(o) - id, err := g.allocID() + id, err := w.allocID() if err != nil { return 0, err } - g.objects[id] = Line{ - Id: id, - X1: x1f, - Y1: y1f, - X2: x2f, - Y2: y2f, - Priority: o.Priority, - StyleID: styleID, + obj := Line{ + Id: id, + X1: x1f, + Y1: y1f, + X2: x2f, + Y2: y2f, + Priority: o.Priority, + // StyleID: styleID, HitSlopPx: o.HitSlopPx, } - g.indexDirty = true - g.rebuildIndexFromLastState() + obj.Class = o.Class + 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 } -func (g *World) resolvePointStyleID(o PointOptions) StyleID { - if o.hasStyleID { - return o.StyleID - } - if o.Override.IsZero() { - return StyleIDDefaultPoint - } - return g.styles.AddDerived(StyleIDDefaultPoint, o.Override) -} +// func (g *World) resolvePointStyleID(o PointOptions) StyleID { +// if o.hasStyleID { +// return o.StyleID +// } +// if o.Override.IsZero() { +// return StyleIDDefaultPoint +// } +// return g.styles.AddDerived(StyleIDDefaultPoint, o.Override) +// } -func (g *World) resolveCircleStyleID(o CircleOptions) StyleID { - if o.hasStyleID { - return o.StyleID - } - if o.Override.IsZero() { - return StyleIDDefaultCircle - } - return g.styles.AddDerived(StyleIDDefaultCircle, o.Override) -} +// func (g *World) resolveCircleStyleID(o CircleOptions) StyleID { +// if o.hasStyleID { +// return o.StyleID +// } +// if o.Override.IsZero() { +// return StyleIDDefaultCircle +// } +// return g.styles.AddDerived(StyleIDDefaultCircle, o.Override) +// } -func (g *World) resolveLineStyleID(o LineOptions) StyleID { - if o.hasStyleID { - return o.StyleID - } - if o.Override.IsZero() { - return StyleIDDefaultLine - } - return g.styles.AddDerived(StyleIDDefaultLine, o.Override) -} +// func (g *World) resolveLineStyleID(o LineOptions) StyleID { +// if o.hasStyleID { +// return o.StyleID +// } +// if o.Override.IsZero() { +// return StyleIDDefaultLine +// } +// return g.styles.AddDerived(StyleIDDefaultLine, o.Override) +// } // worldToCellX converts a fixed-point X coordinate to a grid column index. -func (g *World) worldToCellX(x int) int { - return worldToCell(x, g.W, g.cols, g.cellSize) +func (w *World) worldToCellX(x int) int { + return worldToCell(x, w.W, w.cols, w.cellSize) } // worldToCellY converts a fixed-point Y coordinate to a grid row index. -func (g *World) worldToCellY(y int) int { - return worldToCell(y, g.H, g.rows, g.cellSize) +func (w *World) worldToCellY(y int) int { + return worldToCell(y, w.H, w.rows, w.cellSize) } // resetGrid recreates the spatial grid with the given cell size // and clears all previous indexing state. -func (g *World) resetGrid(cellSize int) { +func (w *World) resetGrid(cellSize int) { if cellSize <= 0 { panic("resetGrid: invalid cell size") } - g.cellSize = cellSize - g.cols = ceilDiv(g.W, g.cellSize) - g.rows = ceilDiv(g.H, g.cellSize) + w.cellSize = cellSize + w.cols = ceilDiv(w.W, w.cellSize) + w.rows = ceilDiv(w.H, w.cellSize) - g.grid = make([][][]MapItem, g.rows) - for row := range g.grid { - g.grid[row] = make([][]MapItem, g.cols) + w.grid = make([][][]MapItem, w.rows) + for row := range w.grid { + w.grid[row] = make([][]MapItem, w.cols) } } // indexObject inserts a single object into all grid cells touched by its // indexing representation. Points are inserted into one cell, while circles // 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) { case Point: - col := g.worldToCellX(mapItem.X) - row := g.worldToCellY(mapItem.Y) - g.grid[row][col] = append(g.grid[row][col], mapItem) + col := w.worldToCellX(mapItem.X) + row := w.worldToCellY(mapItem.Y) + w.grid[row][col] = append(w.grid[row][col], mapItem) case Line: x1 := mapItem.X1 @@ -342,8 +565,8 @@ func (g *World) indexObject(o MapItem) { x2 := mapItem.X2 y2 := mapItem.Y2 - x1, x2 = shortestWrappedDelta(x1, x2, g.W) - y1, y2 = shortestWrappedDelta(y1, y2, g.H) + x1, x2 = shortestWrappedDelta(x1, x2, w.W) + y1, y2 = shortestWrappedDelta(y1, y2, w.H) minX := min(x1, x2) maxX := max(x1, x2) @@ -357,10 +580,14 @@ func (g *World) indexObject(o MapItem) { maxY++ } - g.indexBBox(mapItem, minX, maxX, minY, maxY) + w.indexBBox(mapItem, minX, maxX, minY, maxY) 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: 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 // torus boundaries. The bbox is split into wrapped in-world rectangles first, // then all covered grid cells are populated. -func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { - rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY) +func (w *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { + rects := splitByWrap(w.W, w.H, minX, maxX, minY, maxY) for _, r := range rects { - colStart := g.worldToCellX(r.minX) - colEnd := g.worldToCellX(r.maxX - 1) + colStart := w.worldToCellX(r.minX) + colEnd := w.worldToCellX(r.maxX - 1) - rowStart := g.worldToCellY(r.minY) - rowEnd := g.worldToCellY(r.maxY - 1) + rowStart := w.worldToCellY(r.minY) + rowEnd := w.worldToCellY(r.maxY - 1) for col := colStart; col <= colEnd; col++ { 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. // 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 // Remember params for autonomous reindex after Add/Remove. - g.index.initialized = true - g.index.viewportW = viewportWidthPx - g.index.viewportH = viewportHeightPx - g.index.zoomFp = zoomFp + w.index.initialized = true + w.index.viewportW = viewportWidthPx + w.index.viewportH = viewportHeightPx + w.index.zoomFp = zoomFp - g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp) - g.indexDirty = false + w.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp) + w.indexDirty = false } // 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) cellsAcrossMin := 8 @@ -412,9 +639,50 @@ func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx in cellSize := visibleMin / cellsAcrossMin cellSize = clamp(cellSize, cellSizeMin, cellSizeMax) - g.resetGrid(cellSize) + w.resetGrid(cellSize) - for _, o := range g.objects { - g.indexObject(o) + for _, o := range w.objects { + 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) +} diff --git a/client/world/world_test.go b/client/world/world_test.go index cd1ff59..28d1b77 100644 --- a/client/world/world_test.go +++ b/client/world/world_test.go @@ -7,6 +7,7 @@ import ( func newIndexedTestWorld() *World { w := NewWorld(10, 10) + w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) // 5x5 grid. return w } diff --git a/client/world/zoom.go b/client/world/zoom.go index 1e5cee0..3f7b6bf 100644 --- a/client/world/zoom.go +++ b/client/world/zoom.go @@ -100,7 +100,7 @@ func correctCameraZoomFp( // // currentZoom is the user-facing zoom multiplier in floating-point form. // The result is returned in the same representation. -func (g *World) CorrectCameraZoom( +func (w *World) CorrectCameraZoom( currentZoom float64, viewportWidthPx int, viewportHeightPx int, @@ -110,8 +110,8 @@ func (g *World) CorrectCameraZoom( currentZoomFp, viewportWidthPx, viewportHeightPx, - g.W, - g.H, + w.W, + w.H, MIN_ZOOM, MAX_ZOOM, )