feat: primitive styling

This commit is contained in:
IliaDenisov
2026-03-07 17:01:22 +02:00
parent 477e656008
commit e4b956232f
18 changed files with 1264 additions and 175 deletions
+1
View File
@@ -18,6 +18,7 @@ type interactiveRaster struct {
onScrolled func(*fyne.ScrollEvent)
onDragged func(*fyne.DragEvent)
onDragEnd func()
onTapped *fyne.PointEvent
}
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
+19 -4
View File
@@ -2,6 +2,7 @@ package client
import (
"image"
"image/color"
"math"
"sync"
@@ -196,8 +197,8 @@ func NewEditor() *editor {
world: w,
wp: &world.RenderParams{
CameraZoom: 1.0,
CameraXWorldFp: 300 * world.SCALE,
CameraYWorldFp: 300 * world.SCALE,
CameraXWorldFp: w.W / 2,
CameraYWorldFp: w.H / 2,
// Viewport sizes and margins will be filled from draw(w,h).
Options: &world.RenderOptions{DisableWrapScroll: false},
},
@@ -234,7 +235,17 @@ func NewEditor() *editor {
}
func testWorldInit(w *world.World) {
if _, err := w.AddCircle(150, 150, 50); err != nil {
lineStyle := w.AddStyleLine(world.StyleOverride{
StrokeColor: color.RGBA{R: 0, G: 255, B: 0, A: 255},
StrokeWidthPx: new(3.0),
StrokeDashes: new([]float64{10.}),
})
circleStyle := w.AddStyleCircle(world.StyleOverride{
FillColor: color.RGBA{R: 255, G: 255, B: 0, A: 255},
})
if _, err := w.AddCircle(150, 150, 50, world.CircleWithStyleID(circleStyle)); err != nil {
panic(err)
}
@@ -245,7 +256,11 @@ func testWorldInit(w *world.World) {
panic(err)
}
if _, err := w.AddLine(100, 20, 200, 30); err != nil {
// if _, err := w.AddLine(100, 20, 200, 30); err != nil {
// panic(err)
// }
if _, err := w.AddLine(100, 20, 200, 30, world.LineWithStyleID(lineStyle), world.LineWithPriority(500)); err != nil {
panic(err)
}
+3
View File
@@ -113,6 +113,9 @@ func (e *editor) renderRasterImage(viewportW, viewportH int, p world.RenderParam
canvasH := p.CanvasHeightPx()
e.ensureDrawerCanvas(canvasW, canvasH)
// Savety clamp
e.world.ClampRenderParamsNoWrap(&p)
// 4) Render into expanded canvas backing (full or incremental is decided inside world.Render).
_ = e.world.Render(e.drawer, p) // handle error in your real code
+41 -29
View File
@@ -67,18 +67,13 @@ type PrimitiveDrawer interface {
// Fill renders the current path using the current fill state.
Fill()
// CopyShift shifts the current backing image by (dx, dy) in canvas pixels.
// dx > 0 shifts the image to the right, dy > 0 shifts the image down.
// Newly exposed areas are cleared to transparent.
// CopyShift shifts backing pixels by (dx,dy). Newly exposed areas become transparent/undefined;
// caller is expected to ClearRectTo() the dirty areas before drawing.
CopyShift(dx, dy int)
// ClearAll clears the entire backing canvas to transparent.
// Must NOT affect the current clip state.
ClearAll()
// ClearRect clears a pixel rectangle to transparent.
// Must NOT affect the current clip state.
ClearRect(x, y, w, h int)
// Clear operations must NOT change clip state.
ClearAllTo(bg color.Color)
ClearRectTo(x, y, w, h int, bg color.Color)
}
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
@@ -283,44 +278,61 @@ func (d *GGDrawer) CopyShift(dx, dy int) {
copy(img.Pix, d.scratch.Pix)
}
// ClearAll clears the whole RGBA backing image to transparent.
// It does not touch gg clip state.
func (d *GGDrawer) ClearAll() {
func (d *GGDrawer) ClearAllTo(bg color.Color) {
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearAll: backing image is not *image.RGBA")
panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA")
}
for i := range img.Pix {
img.Pix[i] = 0
r, g, b, a := bg.RGBA()
// Convert from 16-bit range to 8-bit.
R := byte(r >> 8)
G := byte(g >> 8)
B := byte(b >> 8)
A := byte(a >> 8)
p := img.Pix
for i := 0; i+3 < len(p); i += 4 {
p[i+0] = R
p[i+1] = G
p[i+2] = B
p[i+3] = A
}
}
// ClearRect clears a region to transparent. It does not touch gg clip state.
// Rectangle is clamped to the image bounds.
func (d *GGDrawer) ClearRect(x, y, w, h int) {
func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
if w <= 0 || h <= 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearRect: backing image is not *image.RGBA")
panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA")
}
b := img.Bounds()
x0 := max(x, b.Min.X)
y0 := max(y, b.Min.Y)
x1 := min(x+w, b.Max.X)
y1 := min(y+h, b.Max.Y)
bounds := img.Bounds()
x0 := max(x, bounds.Min.X)
y0 := max(y, bounds.Min.Y)
x1 := min(x+w, bounds.Max.X)
y1 := min(y+h, bounds.Max.Y)
if x0 >= x1 || y0 >= y1 {
return
}
// Zero rows.
r, g, b, a := bg.RGBA()
R := byte(r >> 8)
G := byte(g >> 8)
B := byte(b >> 8)
A := byte(a >> 8)
rowBytes := (x1 - x0) * 4
for yy := y0; yy < y1; yy++ {
off := yy*img.Stride + x0*4
n := (x1 - x0) * 4
for i := 0; i < n; i++ {
img.Pix[off+i] = 0
for i := 0; i < rowBytes; i += 4 {
img.Pix[off+i+0] = R
img.Pix[off+i+1] = G
img.Pix[off+i+2] = B
img.Pix[off+i+3] = A
}
}
}
+11 -9
View File
@@ -103,27 +103,29 @@ func TestGGDrawerResetClipClearsClip(t *testing.T) {
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
func TestGGDrawerClearRect_ClearsPixels(t *testing.T) {
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
t.Parallel()
dc := gg.NewContext(10, 10)
dr := &GGDrawer{DC: dc}
// Draw something everywhere.
// Draw something to ensure we overwrite non-background.
dr.SetFillColor(color.RGBA{R: 255, A: 255})
dr.ClipRect(0, 0, 10, 10)
dr.AddCircle(5, 5, 5)
dr.Fill()
dr.ResetClip()
// Clear a 2x2 rect at (1,1)
dr.ClearRect(1, 1, 2, 2)
bg := color.RGBA{A: 255} // black
dr.ClearRectTo(1, 1, 2, 2, bg)
img := dc.Image()
_, _, _, a := img.At(1, 1).RGBA()
require.Equal(t, uint32(0), a)
r, g, b, a := img.At(1, 1).RGBA()
// Pixel outside should remain non-zero alpha.
require.Equal(t, uint32(0), r)
require.Equal(t, uint32(0), g)
require.Equal(t, uint32(0), b)
require.Equal(t, uint32(0xffff), a)
// Pixel outside cleared rect should still have non-zero alpha.
_, _, _, a2 := img.At(5, 5).RGBA()
require.NotEqual(t, uint32(0), a2)
}
+5 -4
View File
@@ -231,10 +231,11 @@ func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
}
func (d *fakePrimitiveDrawer) ClearAll() {
d.snapshotCommand("ClearAll")
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
// Store as a command; tests usually only care that it was called.
d.snapshotCommand("ClearAllTo")
}
func (d *fakePrimitiveDrawer) ClearRect(x, y, w, h int) {
d.snapshotCommand("ClearRect", float64(x), float64(y), float64(w), float64(h))
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
}
+119
View File
@@ -0,0 +1,119 @@
package world
// Functional options for primitive creation.
// Defaults are applied first, then user options override.
type PointOpt func(*PointOptions)
type PointOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
hasStyleID bool
}
func defaultPointOptions() PointOptions {
return PointOptions{
Priority: DefaultPriorityPoint,
StyleID: StyleIDDefaultPoint,
}
}
func PointWithPriority(p int) PointOpt {
return func(o *PointOptions) {
o.Priority = p
}
}
// PointWithStyleID forces the point to use a pre-registered style.
func PointWithStyleID(id StyleID) PointOpt {
return func(o *PointOptions) {
o.StyleID = id
o.hasStyleID = true
// Explicit style ID wins over overrides.
o.Override = StyleOverride{}
}
}
// PointWithStyleOverride derives a style from default point style and applies overrides.
// If you also set StyleID, StyleID wins.
func PointWithStyleOverride(ov StyleOverride) PointOpt {
return func(o *PointOptions) {
o.Override = ov
}
}
type CircleOpt func(*CircleOptions)
type CircleOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
hasStyleID bool
}
func defaultCircleOptions() CircleOptions {
return CircleOptions{
Priority: DefaultPriorityCircle,
StyleID: StyleIDDefaultCircle,
}
}
func CircleWithPriority(p int) CircleOpt {
return func(o *CircleOptions) {
o.Priority = p
}
}
func CircleWithStyleID(id StyleID) CircleOpt {
return func(o *CircleOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
return func(o *CircleOptions) {
o.Override = ov
}
}
type LineOpt func(*LineOptions)
type LineOptions struct {
Priority int
StyleID StyleID
Override StyleOverride
hasStyleID bool
}
func defaultLineOptions() LineOptions {
return LineOptions{
Priority: DefaultPriorityLine,
StyleID: StyleIDDefaultLine,
}
}
func LineWithPriority(p int) LineOpt {
return func(o *LineOptions) {
o.Priority = p
}
}
func LineWithStyleID(id StyleID) LineOpt {
return func(o *LineOptions) {
o.StyleID = id
o.hasStyleID = true
o.Override = StyleOverride{}
}
}
func LineWithStyleOverride(ov StyleOverride) LineOpt {
return func(o *LineOptions) {
o.Override = ov
}
}
+15
View File
@@ -13,6 +13,11 @@ type MapItem interface {
type Point struct {
Id uuid.UUID
X, Y int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
}
// Line is a line segment primitive in fixed-point world coordinates.
@@ -20,6 +25,11 @@ type Line struct {
Id uuid.UUID
X1, Y1 int
X2, Y2 int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
}
// Circle is a circle primitive in fixed-point world coordinates.
@@ -27,6 +37,11 @@ type Circle struct {
Id uuid.UUID
X, Y int
Radius int
// Priority controls per-object draw ordering. Smaller draws earlier.
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
}
// ID returns the point identifier.
+67
View File
@@ -0,0 +1,67 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_SortsByPriorityWithinTile(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Same tile. Priorities deliberately mixed.
_, err := w.AddCircle(5, 5, 1, CircleWithPriority(500))
require.NoError(t, err)
_, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100))
require.NoError(t, err)
_, err = w.AddPoint(5, 6, PointWithPriority(300))
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
// default: wrap on
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We verify the first occurrence of each primitive kind follows priority order.
// Since each object is drawn with Add* + Fill/Stroke immediately, order should match.
cmds := d.Commands()
firstLine := indexOfFirst(cmds, "AddLine")
firstCircle := indexOfFirst(cmds, "AddCircle")
firstPoint := indexOfFirst(cmds, "AddPoint")
require.NotEqual(t, -1, firstLine)
require.NotEqual(t, -1, firstCircle)
require.NotEqual(t, -1, firstPoint)
require.Less(t, firstLine, firstPoint)
require.Less(t, firstPoint, firstCircle) // 300 before 500
}
func indexOfFirst(cmds []fakeDrawerCommand, name string) int {
for i, c := range cmds {
if c.Name == name {
return i
}
}
return -1
}
+21 -62
View File
@@ -2,6 +2,7 @@ package world
import (
"errors"
"image/color"
"time"
)
@@ -25,6 +26,10 @@ type RenderOptions struct {
// or as a bounded plane without wrap (true).
// Default is false.
DisableWrapScroll bool
// BackgroundColor is used to clear full redraw and dirty regions.
// If nil, default background is opaque black.
BackgroundColor color.Color
}
var (
@@ -166,6 +171,15 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
return err
}
bg := 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
}
}
defer func() {
if !params.Debug {
return
@@ -203,17 +217,6 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
policy = *params.Options.Incremental
}
// --- Prepare style / layers (same as before) ---
style := DefaultRenderStyle()
if params.Options != nil && params.Options.Style != nil {
style = *params.Options.Style
}
layers := []RenderLayer{RenderLayerPoints, RenderLayerCircles, RenderLayerLines}
if params.Options != nil && len(params.Options.Layers) > 0 {
layers = params.Options.Layers
}
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
// --- Try incremental path first when state is initialized and geometry matches ---
@@ -239,30 +242,16 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
if len(toDraw) > 0 {
for _, r := range toDraw {
drawer.ClearRect(r.X, r.Y, r.W, r.H)
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
}
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
}
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
w.drawPlanSinglePass(drawer, catchUpPlan, allowWrap)
}
w.renderState.pendingDirty = remaining
@@ -305,7 +294,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
dirtyToDraw := inc.Dirty
for _, r := range dirtyToDraw {
drawer.ClearRect(r.X, r.Y, r.W, r.H)
drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
}
// Additionally redraw a bounded portion of deferred dirty regions.
@@ -320,22 +309,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
return err
}
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
}
w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap)
// State already updated by ComputePanShiftPx (lastWorldRect advanced).
return nil
@@ -352,24 +326,9 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
if err != nil {
return err
}
drawer.ClearAll()
for _, layer := range layers {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, plan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, plan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
}
drawer.ClearAllTo(bg)
w.drawPlanSinglePass(drawer, plan, allowWrap)
return w.CommitFullRedrawState(params)
}
+165
View File
@@ -0,0 +1,165 @@
package world
import (
"sort"
"github.com/google/uuid"
)
// drawKind is used only for stable tie-breaking when priorities are equal.
type drawKind int
const (
drawKindLine drawKind = iota
drawKindCircle
drawKindPoint
)
type drawItem struct {
kind drawKind
priority int
id uuid.UUID
styleID StyleID
// Exactly one of these is set.
p *Point
c *Circle
l *Line
}
// drawPlanSinglePass renders a plan using a single ordered pass per tile.
// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism.
//
// allowWrap controls torus behavior:
// - true: circles/points produce wrap copies, lines use torus-shortest segments
// - false: no copies, lines drawn directly as stored
func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool) {
var lastStyleID StyleID = StyleIDInvalid
var lastStyle Style
applyStyle := func(styleID StyleID) {
if styleID == lastStyleID {
return
}
s, ok := w.styles.Get(styleID)
if !ok {
// Unknown style ID is a programming/config error.
panic("render: unknown style ID")
}
// Apply style state. Some fields may be nil intentionally.
if s.FillColor != nil {
drawer.SetFillColor(s.FillColor)
}
if s.StrokeColor != nil {
drawer.SetStrokeColor(s.StrokeColor)
}
drawer.SetLineWidth(s.StrokeWidthPx)
if len(s.StrokeDashes) > 0 {
drawer.SetDash(s.StrokeDashes...)
} else {
// Ensure solid line when switching from dashed style.
drawer.SetDash()
}
drawer.SetDashOffset(s.StrokeDashOffset)
lastStyleID = styleID
lastStyle = s
}
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Collect items for this tile.
items := make([]drawItem, 0, len(td.Candidates))
for _, it := range td.Candidates {
switch v := it.(type) {
case Point:
vv := v
items = append(items, drawItem{
kind: drawKindPoint,
priority: vv.Priority,
id: vv.Id,
styleID: vv.StyleID,
p: &vv,
})
case Circle:
vv := v
items = append(items, drawItem{
kind: drawKindCircle,
priority: vv.Priority,
id: vv.Id,
styleID: vv.StyleID,
c: &vv,
})
case Line:
vv := v
items = append(items, drawItem{
kind: drawKindLine,
priority: vv.Priority,
id: vv.Id,
styleID: vv.StyleID,
l: &vv,
})
default:
// Unknown map items should not exist.
panic("render: unknown map item type")
}
}
if len(items) == 0 {
continue
}
sort.Slice(items, func(i, j int) bool {
a, b := items[i], items[j]
if a.priority != b.priority {
return a.priority < b.priority
}
if a.kind != b.kind {
return a.kind < b.kind
}
return uuidLess(a.id, b.id)
})
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, di := range items {
applyStyle(di.styleID)
switch di.kind {
case drawKindPoint:
w.drawPointInTile(drawer, plan, td, *di.p, allowWrap, lastStyle)
case drawKindCircle:
w.drawCircleInTile(drawer, plan, td, *di.c, allowWrap, lastStyle)
case drawKindLine:
w.drawLineInTile(drawer, plan, td, *di.l, allowWrap)
default:
panic("render: unknown draw kind")
}
}
drawer.Restore()
}
}
func uuidLess(a, b uuid.UUID) bool {
aa := a[:]
bb := b[:]
for i := 0; i < len(aa); i++ {
if aa[i] < bb[i] {
return true
}
if aa[i] > bb[i] {
return false
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
package world
// drawPointInTile draws point marker copies that intersect the tile.
// lastStyle is already applied; it provides PointRadiusPx.
func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) {
rPx := lastStyle.PointRadiusPx
if rPx <= 0 {
// Nothing visible.
return
}
// Convert screen radius to world-fixed conservatively.
rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp)
var shifts []wrapShift
if allowWrap {
shifts = pointWrapShifts(p, rWorldFp, w.W, w.H)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
continue
}
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddPoint(float64(px), float64(py), rPx)
// For points we use Fill if fill is configured, otherwise Stroke if stroke is configured.
if lastStyle.FillColor != nil {
drawer.Fill()
} else if lastStyle.StrokeColor != nil {
drawer.Stroke()
}
}
}
func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
var shifts []wrapShift
if allowWrap {
shifts = circleWrapShifts(c, w.W, w.H)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp)
for _, s := range shifts {
if allowWrap && !circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, w.W, w.H) {
continue
}
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
if lastStyle.FillColor != nil {
drawer.Fill()
} else if lastStyle.StrokeColor != nil {
drawer.Stroke()
}
}
}
func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) {
var segs []lineSeg
if allowWrap {
segs = torusShortestLineSegments(l, w.W, w.H)
} else {
segs = []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
}
for _, s := range segs {
// Project into tile/canvas.
x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2))
drawer.Stroke()
}
}
@@ -0,0 +1,205 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Create a derived circle style so we can observe a style application transition.
red := color.RGBA{R: 255, A: 255}
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
_, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100))
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
cmds := d.Commands()
iSetFill := indexOfFirstName(cmds, "SetFillColor")
iAddCircle := indexOfFirstName(cmds, "AddCircle")
require.NotEqual(t, -1, iSetFill)
require.NotEqual(t, -1, iAddCircle)
require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle")
}
func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Two lines with the same default line style and same priority.
_, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100))
require.NoError(t, err)
_, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We expect style application at least once.
setWidth := d.CommandsByName("SetLineWidth")
require.NotEmpty(t, setWidth)
// The key batching assertion: style setters should not be called twice *between* two AddLine calls.
cmds := d.Commands()
line1 := indexOfFirstName(cmds, "AddLine")
require.NotEqual(t, -1, line1)
line2 := indexOfNextName(cmds, "AddLine", line1+1)
require.NotEqual(t, -1, line2)
// Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset,
// because StyleID is the same and the renderer caches lastStyleID.
for i := line1 + 1; i < line2; i++ {
switch cmds[i].Name {
case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor":
t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i)
}
}
}
func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Two circles, different derived fill colors => different StyleIDs.
red := color.RGBA{R: 255, A: 255}
green := color.RGBA{G: 255, A: 255}
styleRed := w.AddStyleCircle(StyleOverride{FillColor: red})
styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green})
_, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100))
require.NoError(t, err)
_, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101))
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
cmds := d.Commands()
firstCircle := indexOfFirstName(cmds, "AddCircle")
secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1)
require.NotEqual(t, -1, firstCircle)
require.NotEqual(t, -1, secondCircle)
// There must be at least one SetFillColor before each circle.
// And importantly, we expect a SetFillColor BETWEEN the two circles due to style change.
setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle)
require.NotEqual(t, -1, setBeforeFirst)
setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle)
require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs")
}
/* ---------- helper functions for fake command slices ---------- */
func indexOfFirstName(cmds []fakeDrawerCommand, name string) int {
for i, c := range cmds {
if c.Name == name {
return i
}
}
return -1
}
func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int {
for i := start; i < len(cmds); i++ {
if cmds[i].Name == name {
return i
}
}
return -1
}
func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int {
if before > len(cmds) {
before = len(cmds)
}
for i := before - 1; i >= 0; i-- {
if cmds[i].Name == name {
return i
}
}
return -1
}
func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int {
if start < 0 {
start = 0
}
if end > len(cmds) {
end = len(cmds)
}
for i := start; i < end; i++ {
if cmds[i].Name == name {
return i
}
}
return -1
}
-58
View File
@@ -1,58 +0,0 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldRender_AppliesLineStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddLine(9, 5, 1, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
custom := DefaultRenderStyle()
custom.LineWidthPx = 3
custom.LineDash = []float64{5, 2}
custom.LineDashOffset = 1
custom.LineStroke = color.RGBA{R: 255, A: 255}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 2,
MarginYPx: 2,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Layers: []RenderLayer{RenderLayerLines},
Style: &custom,
},
}
d := &fakePrimitiveDrawer{}
err = w.Render(d, params)
require.NoError(t, err)
// There must be at least one AddLine call, and every AddLine must observe
// the configured line state snapshot.
cmds := d.CommandsByName("AddLine")
require.NotEmpty(t, cmds)
for _, cmd := range cmds {
require.Equal(t, 3.0, cmd.LineWidth)
require.Equal(t, []float64{5, 2}, cmd.Dashes)
require.Equal(t, 1.0, cmd.DashOffset)
require.Equal(t, color.RGBA{R: 255, A: 255}, cmd.StrokeColor)
}
}
+201
View File
@@ -0,0 +1,201 @@
package world
import (
"image/color"
"sync"
)
// StyleID references a fully-materialized style stored in StyleTable.
type StyleID int
const (
// StyleIDInvalid means "no style". It should not be used for rendering.
StyleIDInvalid StyleID = 0
// Built-in default styles (stable IDs).
StyleIDDefaultLine StyleID = 1
StyleIDDefaultCircle StyleID = 2
StyleIDDefaultPoint StyleID = 3
)
// Default priorities (smaller draws earlier), step=100.
const (
DefaultPriorityLine = 100
DefaultPriorityCircle = 200
DefaultPriorityPoint = 300
)
// Style is a fully resolved style used by the renderer.
// All fields are concrete values; no "optional" markers here.
// Optionality is handled by StyleOverride during style creation.
type Style struct {
// FillColor is used for Fill() operations (points/circles typically).
// If nil, the renderer may treat it as "do not fill" depending on primitive.
FillColor color.Color
// StrokeColor is used for Stroke() operations (lines typically).
// If nil, the renderer may treat it as "do not stroke" depending on primitive.
StrokeColor color.Color
// StrokeWidthPx is a screen-space stroke width in pixels.
StrokeWidthPx float64
// StrokeDashes is the dash pattern in pixels. nil/empty means "solid".
StrokeDashes []float64
// StrokeDashOffset is the dash phase in pixels.
StrokeDashOffset float64
// PointRadiusPx is a screen-space radius for Point markers.
PointRadiusPx float64
}
// StyleOverride describes partial modifications applied to a base Style.
// Fields set to nil mean "do not override".
type StyleOverride struct {
FillColor color.Color
StrokeColor color.Color
StrokeWidthPx *float64
StrokeDashes *[]float64
StrokeDashOffset *float64
PointRadiusPx *float64
}
// IsZero reports whether override does not specify any fields.
func (o StyleOverride) IsZero() bool {
return o.FillColor == nil &&
o.StrokeColor == nil &&
o.StrokeWidthPx == nil &&
o.StrokeDashes == nil &&
o.StrokeDashOffset == nil &&
o.PointRadiusPx == nil
}
// Apply applies override to base style and returns a new fully resolved style.
// It copies slices defensively to avoid aliasing.
func (o StyleOverride) Apply(base Style) Style {
out := base
if o.FillColor != nil {
out.FillColor = o.FillColor
}
if o.StrokeColor != nil {
out.StrokeColor = o.StrokeColor
}
if o.StrokeWidthPx != nil {
out.StrokeWidthPx = *o.StrokeWidthPx
}
if o.StrokeDashes != nil {
// Copy to avoid future mutation by caller.
src := *o.StrokeDashes
if src == nil {
out.StrokeDashes = nil
} else {
dst := make([]float64, len(src))
copy(dst, src)
out.StrokeDashes = dst
}
}
if o.StrokeDashOffset != nil {
out.StrokeDashOffset = *o.StrokeDashOffset
}
if o.PointRadiusPx != nil {
out.PointRadiusPx = *o.PointRadiusPx
}
return out
}
// StyleTable stores fully resolved styles and provides stable lookups by StyleID.
// It also holds three built-in defaults for Line/Circle/Point.
type StyleTable struct {
mu sync.RWMutex
nextID StyleID
styles map[StyleID]Style
}
// NewStyleTable creates a new style table with built-in default styles.
// The default values are intentionally simple and stable.
func NewStyleTable() *StyleTable {
t := &StyleTable{
nextID: StyleIDDefaultPoint + 1,
styles: make(map[StyleID]Style, 16),
}
// Defaults: conservative, deterministic.
// Colors: opaque black. (Callers can override.)
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
t.styles[StyleIDDefaultLine] = Style{
FillColor: nil,
StrokeColor: white,
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultCircle] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultPoint] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 2.0,
}
return t
}
// Get returns a style by id.
func (t *StyleTable) Get(id StyleID) (Style, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.styles[id]
if !ok {
return Style{}, false
}
// Defensive copy of slices.
if s.StrokeDashes != nil {
cp := make([]float64, len(s.StrokeDashes))
copy(cp, s.StrokeDashes)
s.StrokeDashes = cp
}
return s, true
}
// AddDerived creates a new style based on baseID with an override applied.
// It returns the new style ID.
func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID {
t.mu.Lock()
defer t.mu.Unlock()
base, ok := t.styles[baseID]
if !ok {
panic("StyleTable.AddDerived: unknown base style ID")
}
derived := override.Apply(base)
id := t.nextID
t.nextID++
// Defensive copy of slices on store.
if derived.StrokeDashes != nil {
cp := make([]float64, len(derived.StrokeDashes))
copy(cp, derived.StrokeDashes)
derived.StrokeDashes = cp
}
t.styles[id] = derived
return id
}
+95
View File
@@ -0,0 +1,95 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
t.Parallel()
base := Style{
FillColor: color.RGBA{R: 1, A: 255},
StrokeColor: color.RGBA{G: 2, A: 255},
StrokeWidthPx: 1.0,
StrokeDashes: []float64{3, 1},
StrokeDashOffset: 0.5,
PointRadiusPx: 2.0,
}
newWidth := 5.0
newRadius := 7.0
override := StyleOverride{
StrokeWidthPx: &newWidth,
PointRadiusPx: &newRadius,
// Everything else is unset (nil) => must remain from base.
}
out := override.Apply(base)
require.Equal(t, base.FillColor, out.FillColor)
require.Equal(t, base.StrokeColor, out.StrokeColor)
require.Equal(t, 5.0, out.StrokeWidthPx)
require.Equal(t, base.StrokeDashes, out.StrokeDashes)
require.Equal(t, base.StrokeDashOffset, out.StrokeDashOffset)
require.Equal(t, 7.0, out.PointRadiusPx)
}
func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
t.Parallel()
tbl := NewStyleTable()
_, ok := tbl.Get(StyleIDDefaultLine)
require.True(t, ok)
_, ok = tbl.Get(StyleIDDefaultCircle)
require.True(t, ok)
_, ok = tbl.Get(StyleIDDefaultPoint)
require.True(t, ok)
}
func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T) {
t.Parallel()
tbl := NewStyleTable()
dashes := []float64{10, 5}
override := StyleOverride{
StrokeDashes: &dashes,
}
id := tbl.AddDerived(StyleIDDefaultLine, override)
got, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got.StrokeDashes)
// Mutate caller slice; table must not change.
dashes[0] = 999
got2, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got2.StrokeDashes)
// Mutate returned slice; table must not change.
got2.StrokeDashes[0] = 123
got3, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got3.StrokeDashes)
}
func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
t.Parallel()
require.Equal(t, 100, DefaultPriorityLine)
require.Equal(t, 200, DefaultPriorityCircle)
require.Equal(t, 300, DefaultPriorityPoint)
require.Less(t, DefaultPriorityLine, DefaultPriorityCircle)
require.Less(t, DefaultPriorityCircle, DefaultPriorityPoint)
}
+99 -9
View File
@@ -21,6 +21,7 @@ type World struct {
rows, cols int
objects map[uuid.UUID]MapItem
renderState rendererIncrementalState
styles *StyleTable
}
// NewWorld constructs a new world with the given real dimensions.
@@ -34,6 +35,7 @@ func NewWorld(width, height int) *World {
H: height * SCALE,
cellSize: 1,
objects: make(map[uuid.UUID]MapItem),
styles: NewStyleTable(),
}
}
@@ -46,46 +48,88 @@ func (g *World) checkCoordinate(xf, yf int) bool {
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)
}
// 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)
}
// 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)
}
// 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) (uuid.UUID, error) {
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
}
o := defaultPointOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolvePointStyleID(o)
id := uuid.New()
g.objects[id] = Point{Id: id, X: xf, Y: yf}
g.objects[id] = Point{
Id: id,
X: xf,
Y: yf,
Priority: o.Priority,
StyleID: styleID,
}
return id, nil
}
// 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) (uuid.UUID, error) {
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
rf := fixedPoint(r)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
}
if rf < 0 {
if r < 0 {
return uuid.Nil, errBadRadius
}
o := defaultCircleOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolveCircleStyleID(o)
id := uuid.New()
g.objects[id] = Circle{Id: id, X: xf, Y: yf, Radius: rf}
g.objects[id] = Circle{
Id: id,
X: xf,
Y: yf,
Radius: fixedPoint(r),
Priority: o.Priority,
StyleID: styleID,
}
return id, nil
}
// 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) (uuid.UUID, error) {
func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, error) {
x1f := fixedPoint(x1)
y1f := fixedPoint(y1)
x2f := fixedPoint(x2)
@@ -98,11 +142,57 @@ func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) {
return uuid.Nil, errBadCoordinate
}
o := defaultLineOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolveLineStyleID(o)
id := uuid.New()
g.objects[id] = Line{Id: id, X1: x1f, Y1: y1f, X2: x2f, Y2: y2f}
g.objects[id] = Line{
Id: id,
X1: x1f,
Y1: y1f,
X2: x2f,
Y2: y2f,
Priority: o.Priority,
StyleID: styleID,
}
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) 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)
}
// 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)
+110
View File
@@ -0,0 +1,110 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestAddPoint_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddPoint(1, 1)
require.NoError(t, err)
obj := w.objects[id].(Point)
require.Equal(t, DefaultPriorityPoint, obj.Priority)
require.Equal(t, StyleIDDefaultPoint, obj.StyleID)
}
func TestAddCircle_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddCircle(1, 1, 1)
require.NoError(t, err)
obj := w.objects[id].(Circle)
require.Equal(t, DefaultPriorityCircle, obj.Priority)
require.Equal(t, StyleIDDefaultCircle, obj.StyleID)
}
func TestAddLine_DefaultsPriorityAndStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id, err := w.AddLine(1, 1, 2, 2)
require.NoError(t, err)
obj := w.objects[id].(Line)
require.Equal(t, DefaultPriorityLine, obj.Priority)
require.Equal(t, StyleIDDefaultLine, obj.StyleID)
}
func TestAddStyleLine_ThenUseStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
width := 5.0
ov := StyleOverride{StrokeWidthPx: &width}
styleID := w.AddStyleLine(ov)
id, err := w.AddLine(1, 1, 2, 2, LineWithStyleID(styleID), LineWithPriority(777))
require.NoError(t, err)
obj := w.objects[id].(Line)
require.Equal(t, 777, obj.Priority)
require.Equal(t, styleID, obj.StyleID)
s, ok := w.styles.Get(styleID)
require.True(t, ok)
require.Equal(t, 5.0, s.StrokeWidthPx)
}
func TestAddPoint_WithOverride_CreatesDerivedStyle(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
newRadius := 9.0
ov := StyleOverride{PointRadiusPx: &newRadius}
id, err := w.AddPoint(1, 1, PointWithStyleOverride(ov))
require.NoError(t, err)
obj := w.objects[id].(Point)
require.NotEqual(t, StyleIDDefaultPoint, obj.StyleID)
s, ok := w.styles.Get(obj.StyleID)
require.True(t, ok)
require.Equal(t, 9.0, s.PointRadiusPx)
}
func TestExplicitStyleID_WinsOverOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
red := color.RGBA{R: 255, A: 255}
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
// Try to override radius in options too; StyleID must win, override must be ignored.
width := 123.0
id, err := w.AddCircle(2, 2, 1,
CircleWithStyleID(styleID),
CircleWithStyleOverride(StyleOverride{StrokeWidthPx: &width}),
)
require.NoError(t, err)
obj := w.objects[id].(Circle)
require.Equal(t, styleID, obj.StyleID)
s, ok := w.styles.Get(styleID)
require.True(t, ok)
require.Equal(t, red, s.FillColor)
// width override must not affect styleID.
require.NotEqual(t, 123.0, s.StrokeWidthPx)
}