feat: primitive styling
This commit is contained in:
@@ -18,6 +18,7 @@ type interactiveRaster struct {
|
|||||||
onScrolled func(*fyne.ScrollEvent)
|
onScrolled func(*fyne.ScrollEvent)
|
||||||
onDragged func(*fyne.DragEvent)
|
onDragged func(*fyne.DragEvent)
|
||||||
onDragEnd func()
|
onDragEnd func()
|
||||||
|
onTapped *fyne.PointEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
||||||
|
|||||||
+19
-4
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -196,8 +197,8 @@ func NewEditor() *editor {
|
|||||||
world: w,
|
world: w,
|
||||||
wp: &world.RenderParams{
|
wp: &world.RenderParams{
|
||||||
CameraZoom: 1.0,
|
CameraZoom: 1.0,
|
||||||
CameraXWorldFp: 300 * world.SCALE,
|
CameraXWorldFp: w.W / 2,
|
||||||
CameraYWorldFp: 300 * world.SCALE,
|
CameraYWorldFp: w.H / 2,
|
||||||
// Viewport sizes and margins will be filled from draw(w,h).
|
// Viewport sizes and margins will be filled from draw(w,h).
|
||||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||||
},
|
},
|
||||||
@@ -234,7 +235,17 @@ func NewEditor() *editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testWorldInit(w *world.World) {
|
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)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +256,11 @@ func testWorldInit(w *world.World) {
|
|||||||
panic(err)
|
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)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ func (e *editor) renderRasterImage(viewportW, viewportH int, p world.RenderParam
|
|||||||
canvasH := p.CanvasHeightPx()
|
canvasH := p.CanvasHeightPx()
|
||||||
e.ensureDrawerCanvas(canvasW, canvasH)
|
e.ensureDrawerCanvas(canvasW, canvasH)
|
||||||
|
|
||||||
|
// Savety clamp
|
||||||
|
e.world.ClampRenderParamsNoWrap(&p)
|
||||||
|
|
||||||
// 4) Render into expanded canvas backing (full or incremental is decided inside world.Render).
|
// 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
|
_ = e.world.Render(e.drawer, p) // handle error in your real code
|
||||||
|
|
||||||
|
|||||||
+41
-29
@@ -67,18 +67,13 @@ type PrimitiveDrawer interface {
|
|||||||
// Fill renders the current path using the current fill state.
|
// Fill renders the current path using the current fill state.
|
||||||
Fill()
|
Fill()
|
||||||
|
|
||||||
// CopyShift shifts the current backing image by (dx, dy) in canvas pixels.
|
// CopyShift shifts backing pixels by (dx,dy). Newly exposed areas become transparent/undefined;
|
||||||
// dx > 0 shifts the image to the right, dy > 0 shifts the image down.
|
// caller is expected to ClearRectTo() the dirty areas before drawing.
|
||||||
// Newly exposed areas are cleared to transparent.
|
|
||||||
CopyShift(dx, dy int)
|
CopyShift(dx, dy int)
|
||||||
|
|
||||||
// ClearAll clears the entire backing canvas to transparent.
|
// Clear operations must NOT change clip state.
|
||||||
// Must NOT affect the current clip state.
|
ClearAllTo(bg color.Color)
|
||||||
ClearAll()
|
ClearRectTo(x, y, w, h int, bg color.Color)
|
||||||
|
|
||||||
// ClearRect clears a pixel rectangle to transparent.
|
|
||||||
// Must NOT affect the current clip state.
|
|
||||||
ClearRect(x, y, w, h int)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
|
// 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)
|
copy(img.Pix, d.scratch.Pix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAll clears the whole RGBA backing image to transparent.
|
func (d *GGDrawer) ClearAllTo(bg color.Color) {
|
||||||
// It does not touch gg clip state.
|
|
||||||
func (d *GGDrawer) ClearAll() {
|
|
||||||
img, ok := d.DC.Image().(*image.RGBA)
|
img, ok := d.DC.Image().(*image.RGBA)
|
||||||
if !ok || img == nil {
|
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.
|
func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
|
||||||
// Rectangle is clamped to the image bounds.
|
|
||||||
func (d *GGDrawer) ClearRect(x, y, w, h int) {
|
|
||||||
if w <= 0 || h <= 0 {
|
if w <= 0 || h <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
img, ok := d.DC.Image().(*image.RGBA)
|
img, ok := d.DC.Image().(*image.RGBA)
|
||||||
if !ok || img == nil {
|
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()
|
bounds := img.Bounds()
|
||||||
x0 := max(x, b.Min.X)
|
x0 := max(x, bounds.Min.X)
|
||||||
y0 := max(y, b.Min.Y)
|
y0 := max(y, bounds.Min.Y)
|
||||||
x1 := min(x+w, b.Max.X)
|
x1 := min(x+w, bounds.Max.X)
|
||||||
y1 := min(y+h, b.Max.Y)
|
y1 := min(y+h, bounds.Max.Y)
|
||||||
if x0 >= x1 || y0 >= y1 {
|
if x0 >= x1 || y0 >= y1 {
|
||||||
return
|
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++ {
|
for yy := y0; yy < y1; yy++ {
|
||||||
off := yy*img.Stride + x0*4
|
off := yy*img.Stride + x0*4
|
||||||
n := (x1 - x0) * 4
|
for i := 0; i < rowBytes; i += 4 {
|
||||||
for i := 0; i < n; i++ {
|
img.Pix[off+i+0] = R
|
||||||
img.Pix[off+i] = 0
|
img.Pix[off+i+1] = G
|
||||||
|
img.Pix[off+i+2] = B
|
||||||
|
img.Pix[off+i+3] = A
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,27 +103,29 @@ func TestGGDrawerResetClipClearsClip(t *testing.T) {
|
|||||||
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGGDrawerClearRect_ClearsPixels(t *testing.T) {
|
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dc := gg.NewContext(10, 10)
|
dc := gg.NewContext(10, 10)
|
||||||
dr := &GGDrawer{DC: dc}
|
dr := &GGDrawer{DC: dc}
|
||||||
|
|
||||||
// Draw something everywhere.
|
// Draw something to ensure we overwrite non-background.
|
||||||
dr.SetFillColor(color.RGBA{R: 255, A: 255})
|
dr.SetFillColor(color.RGBA{R: 255, A: 255})
|
||||||
dr.ClipRect(0, 0, 10, 10)
|
|
||||||
dr.AddCircle(5, 5, 5)
|
dr.AddCircle(5, 5, 5)
|
||||||
dr.Fill()
|
dr.Fill()
|
||||||
dr.ResetClip()
|
|
||||||
|
|
||||||
// Clear a 2x2 rect at (1,1)
|
bg := color.RGBA{A: 255} // black
|
||||||
dr.ClearRect(1, 1, 2, 2)
|
dr.ClearRectTo(1, 1, 2, 2, bg)
|
||||||
|
|
||||||
img := dc.Image()
|
img := dc.Image()
|
||||||
_, _, _, a := img.At(1, 1).RGBA()
|
r, g, b, a := img.At(1, 1).RGBA()
|
||||||
require.Equal(t, uint32(0), a)
|
|
||||||
|
|
||||||
// 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()
|
_, _, _, a2 := img.At(5, 5).RGBA()
|
||||||
require.NotEqual(t, uint32(0), a2)
|
require.NotEqual(t, uint32(0), a2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,10 +231,11 @@ func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
|
|||||||
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
|
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *fakePrimitiveDrawer) ClearAll() {
|
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
|
||||||
d.snapshotCommand("ClearAll")
|
// Store as a command; tests usually only care that it was called.
|
||||||
|
d.snapshotCommand("ClearAllTo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *fakePrimitiveDrawer) ClearRect(x, y, w, h int) {
|
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
|
||||||
d.snapshotCommand("ClearRect", float64(x), float64(y), float64(w), float64(h))
|
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ type MapItem interface {
|
|||||||
type Point struct {
|
type Point struct {
|
||||||
Id uuid.UUID
|
Id uuid.UUID
|
||||||
X, Y int
|
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.
|
// Line is a line segment primitive in fixed-point world coordinates.
|
||||||
@@ -20,6 +25,11 @@ type Line struct {
|
|||||||
Id uuid.UUID
|
Id uuid.UUID
|
||||||
X1, Y1 int
|
X1, Y1 int
|
||||||
X2, Y2 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.
|
// Circle is a circle primitive in fixed-point world coordinates.
|
||||||
@@ -27,6 +37,11 @@ type Circle struct {
|
|||||||
Id uuid.UUID
|
Id uuid.UUID
|
||||||
X, Y int
|
X, Y int
|
||||||
Radius 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.
|
// ID returns the point identifier.
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,7 @@ package world
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"image/color"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ type RenderOptions struct {
|
|||||||
// or as a bounded plane without wrap (true).
|
// or as a bounded plane without wrap (true).
|
||||||
// Default is false.
|
// Default is false.
|
||||||
DisableWrapScroll bool
|
DisableWrapScroll bool
|
||||||
|
|
||||||
|
// BackgroundColor is used to clear full redraw and dirty regions.
|
||||||
|
// If nil, default background is opaque black.
|
||||||
|
BackgroundColor color.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -166,6 +171,15 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
return err
|
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() {
|
defer func() {
|
||||||
if !params.Debug {
|
if !params.Debug {
|
||||||
return
|
return
|
||||||
@@ -203,17 +217,6 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
policy = *params.Options.Incremental
|
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
|
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
|
|
||||||
// --- Try incremental path first when state is initialized and geometry matches ---
|
// --- Try incremental path first when state is initialized and geometry matches ---
|
||||||
@@ -239,30 +242,16 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
|
|
||||||
if len(toDraw) > 0 {
|
if len(toDraw) > 0 {
|
||||||
for _, r := range toDraw {
|
for _, r := range toDraw {
|
||||||
drawer.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)
|
plan, err := w.buildRenderPlanStageA(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
|
||||||
|
|
||||||
for _, layer := range layers {
|
catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)
|
||||||
switch layer {
|
w.drawPlanSinglePass(drawer, catchUpPlan, allowWrap)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.renderState.pendingDirty = remaining
|
w.renderState.pendingDirty = remaining
|
||||||
@@ -305,7 +294,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
dirtyToDraw := inc.Dirty
|
dirtyToDraw := inc.Dirty
|
||||||
|
|
||||||
for _, r := range dirtyToDraw {
|
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.
|
// Additionally redraw a bounded portion of deferred dirty regions.
|
||||||
@@ -320,22 +309,7 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
|
dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)
|
||||||
|
w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// State already updated by ComputePanShiftPx (lastWorldRect advanced).
|
// State already updated by ComputePanShiftPx (lastWorldRect advanced).
|
||||||
return nil
|
return nil
|
||||||
@@ -352,24 +326,9 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return w.CommitFullRedrawState(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -21,6 +21,7 @@ type World struct {
|
|||||||
rows, cols int
|
rows, cols int
|
||||||
objects map[uuid.UUID]MapItem
|
objects map[uuid.UUID]MapItem
|
||||||
renderState rendererIncrementalState
|
renderState rendererIncrementalState
|
||||||
|
styles *StyleTable
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorld constructs a new world with the given real dimensions.
|
// NewWorld constructs a new world with the given real dimensions.
|
||||||
@@ -34,6 +35,7 @@ func NewWorld(width, height int) *World {
|
|||||||
H: height * SCALE,
|
H: height * SCALE,
|
||||||
cellSize: 1,
|
cellSize: 1,
|
||||||
objects: make(map[uuid.UUID]MapItem),
|
objects: make(map[uuid.UUID]MapItem),
|
||||||
|
styles: NewStyleTable(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,46 +48,88 @@ func (g *World) checkCoordinate(xf, yf int) bool {
|
|||||||
return true
|
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.
|
// AddPoint validates and stores a point primitive in the world.
|
||||||
// The input coordinates are given in real world units and are converted
|
// The input coordinates are given in real world units and are converted
|
||||||
// to fixed-point before validation.
|
// to fixed-point before validation.
|
||||||
func (g *World) AddPoint(x, y float64) (uuid.UUID, error) {
|
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
|
||||||
xf := fixedPoint(x)
|
xf := fixedPoint(x)
|
||||||
yf := fixedPoint(y)
|
yf := fixedPoint(y)
|
||||||
|
|
||||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||||
return uuid.Nil, errBadCoordinate
|
return uuid.Nil, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o := defaultPointOptions()
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
styleID := g.resolvePointStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
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
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddCircle validates and stores a circle primitive in the world.
|
// AddCircle validates and stores a circle primitive in the world.
|
||||||
// The center and radius are given in real world units and are converted
|
// The center and radius are given in real world units and are converted
|
||||||
// to fixed-point before validation. A zero radius is allowed.
|
// to fixed-point before validation. A zero radius is allowed.
|
||||||
func (g *World) AddCircle(x, y, r float64) (uuid.UUID, error) {
|
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error) {
|
||||||
xf := fixedPoint(x)
|
xf := fixedPoint(x)
|
||||||
yf := fixedPoint(y)
|
yf := fixedPoint(y)
|
||||||
rf := fixedPoint(r)
|
|
||||||
|
|
||||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||||
return uuid.Nil, errBadCoordinate
|
return uuid.Nil, errBadCoordinate
|
||||||
}
|
}
|
||||||
if rf < 0 {
|
if r < 0 {
|
||||||
return uuid.Nil, errBadRadius
|
return uuid.Nil, errBadRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o := defaultCircleOptions()
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
styleID := g.resolveCircleStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
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
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLine validates and stores a line primitive in the world.
|
// AddLine validates and stores a line primitive in the world.
|
||||||
// The endpoints are given in real world units and are converted
|
// The endpoints are given in real world units and are converted
|
||||||
// to fixed-point before validation.
|
// to fixed-point before validation.
|
||||||
func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) {
|
func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, error) {
|
||||||
x1f := fixedPoint(x1)
|
x1f := fixedPoint(x1)
|
||||||
y1f := fixedPoint(y1)
|
y1f := fixedPoint(y1)
|
||||||
x2f := fixedPoint(x2)
|
x2f := fixedPoint(x2)
|
||||||
@@ -98,11 +142,57 @@ func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) {
|
|||||||
return uuid.Nil, errBadCoordinate
|
return uuid.Nil, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o := defaultLineOptions()
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
styleID := g.resolveLineStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
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
|
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.
|
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
||||||
func (g *World) worldToCellX(x int) int {
|
func (g *World) worldToCellX(x int) int {
|
||||||
return worldToCell(x, g.W, g.cols, g.cellSize)
|
return worldToCell(x, g.W, g.cols, g.cellSize)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user