diff --git a/client/canvas.go b/client/canvas.go index 1d8554c..9cfdb51 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -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) { diff --git a/client/editor.go b/client/editor.go index 252b2cd..e90a0bf 100644 --- a/client/editor.go +++ b/client/editor.go @@ -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) } diff --git a/client/ui.go b/client/ui.go index 274a63d..979d351 100644 --- a/client/ui.go +++ b/client/ui.go @@ -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 diff --git a/client/world/drawer.go b/client/world/drawer.go index 4e6e4b3..3973de3 100644 --- a/client/world/drawer.go +++ b/client/world/drawer.go @@ -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 } } } diff --git a/client/world/drawer_test.go b/client/world/drawer_test.go index cabf455..dc197fe 100644 --- a/client/world/drawer_test.go +++ b/client/world/drawer_test.go @@ -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) } diff --git a/client/world/fake_drawer_test.go b/client/world/fake_drawer_test.go index 015f1d4..90bdd70 100644 --- a/client/world/fake_drawer_test.go +++ b/client/world/fake_drawer_test.go @@ -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)) } diff --git a/client/world/options.go b/client/world/options.go new file mode 100644 index 0000000..ea031a2 --- /dev/null +++ b/client/world/options.go @@ -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 + } +} diff --git a/client/world/primitive.go b/client/world/primitive.go index e77f11c..1bca9e3 100644 --- a/client/world/primitive.go +++ b/client/world/primitive.go @@ -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. diff --git a/client/world/render_priority_test.go b/client/world/render_priority_test.go new file mode 100644 index 0000000..8e88200 --- /dev/null +++ b/client/world/render_priority_test.go @@ -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 +} diff --git a/client/world/renderer.go b/client/world/renderer.go index f1839cb..fa9252f 100644 --- a/client/world/renderer.go +++ b/client/world/renderer.go @@ -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) } diff --git a/client/world/renderer_draw.go b/client/world/renderer_draw.go new file mode 100644 index 0000000..0d1fa11 --- /dev/null +++ b/client/world/renderer_draw.go @@ -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 +} diff --git a/client/world/renderer_draw_primitives.go b/client/world/renderer_draw_primitives.go new file mode 100644 index 0000000..e585622 --- /dev/null +++ b/client/world/renderer_draw_primitives.go @@ -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() + } +} diff --git a/client/world/renderer_style_application_test.go b/client/world/renderer_style_application_test.go new file mode 100644 index 0000000..cd76c23 --- /dev/null +++ b/client/world/renderer_style_application_test.go @@ -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 +} diff --git a/client/world/renderer_style_test.go b/client/world/renderer_style_test.go deleted file mode 100644 index 1ba75d7..0000000 --- a/client/world/renderer_style_test.go +++ /dev/null @@ -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) - } -} diff --git a/client/world/style.go b/client/world/style.go new file mode 100644 index 0000000..7e5580c --- /dev/null +++ b/client/world/style.go @@ -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 +} diff --git a/client/world/style_test.go b/client/world/style_test.go new file mode 100644 index 0000000..fe831ca --- /dev/null +++ b/client/world/style_test.go @@ -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) +} diff --git a/client/world/world.go b/client/world/world.go index 9b18867..0d16159 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -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) diff --git a/client/world/world_options_test.go b/client/world/world_options_test.go new file mode 100644 index 0000000..42f2a18 --- /dev/null +++ b/client/world/world_options_test.go @@ -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) +}