feat: primitive styling

This commit is contained in:
IliaDenisov
2026-03-07 17:01:22 +02:00
parent 477e656008
commit e4b956232f
18 changed files with 1264 additions and 175 deletions
+201
View File
@@ -0,0 +1,201 @@
package world
import (
"image/color"
"sync"
)
// StyleID references a fully-materialized style stored in StyleTable.
type StyleID int
const (
// StyleIDInvalid means "no style". It should not be used for rendering.
StyleIDInvalid StyleID = 0
// Built-in default styles (stable IDs).
StyleIDDefaultLine StyleID = 1
StyleIDDefaultCircle StyleID = 2
StyleIDDefaultPoint StyleID = 3
)
// Default priorities (smaller draws earlier), step=100.
const (
DefaultPriorityLine = 100
DefaultPriorityCircle = 200
DefaultPriorityPoint = 300
)
// Style is a fully resolved style used by the renderer.
// All fields are concrete values; no "optional" markers here.
// Optionality is handled by StyleOverride during style creation.
type Style struct {
// FillColor is used for Fill() operations (points/circles typically).
// If nil, the renderer may treat it as "do not fill" depending on primitive.
FillColor color.Color
// StrokeColor is used for Stroke() operations (lines typically).
// If nil, the renderer may treat it as "do not stroke" depending on primitive.
StrokeColor color.Color
// StrokeWidthPx is a screen-space stroke width in pixels.
StrokeWidthPx float64
// StrokeDashes is the dash pattern in pixels. nil/empty means "solid".
StrokeDashes []float64
// StrokeDashOffset is the dash phase in pixels.
StrokeDashOffset float64
// PointRadiusPx is a screen-space radius for Point markers.
PointRadiusPx float64
}
// StyleOverride describes partial modifications applied to a base Style.
// Fields set to nil mean "do not override".
type StyleOverride struct {
FillColor color.Color
StrokeColor color.Color
StrokeWidthPx *float64
StrokeDashes *[]float64
StrokeDashOffset *float64
PointRadiusPx *float64
}
// IsZero reports whether override does not specify any fields.
func (o StyleOverride) IsZero() bool {
return o.FillColor == nil &&
o.StrokeColor == nil &&
o.StrokeWidthPx == nil &&
o.StrokeDashes == nil &&
o.StrokeDashOffset == nil &&
o.PointRadiusPx == nil
}
// Apply applies override to base style and returns a new fully resolved style.
// It copies slices defensively to avoid aliasing.
func (o StyleOverride) Apply(base Style) Style {
out := base
if o.FillColor != nil {
out.FillColor = o.FillColor
}
if o.StrokeColor != nil {
out.StrokeColor = o.StrokeColor
}
if o.StrokeWidthPx != nil {
out.StrokeWidthPx = *o.StrokeWidthPx
}
if o.StrokeDashes != nil {
// Copy to avoid future mutation by caller.
src := *o.StrokeDashes
if src == nil {
out.StrokeDashes = nil
} else {
dst := make([]float64, len(src))
copy(dst, src)
out.StrokeDashes = dst
}
}
if o.StrokeDashOffset != nil {
out.StrokeDashOffset = *o.StrokeDashOffset
}
if o.PointRadiusPx != nil {
out.PointRadiusPx = *o.PointRadiusPx
}
return out
}
// StyleTable stores fully resolved styles and provides stable lookups by StyleID.
// It also holds three built-in defaults for Line/Circle/Point.
type StyleTable struct {
mu sync.RWMutex
nextID StyleID
styles map[StyleID]Style
}
// NewStyleTable creates a new style table with built-in default styles.
// The default values are intentionally simple and stable.
func NewStyleTable() *StyleTable {
t := &StyleTable{
nextID: StyleIDDefaultPoint + 1,
styles: make(map[StyleID]Style, 16),
}
// Defaults: conservative, deterministic.
// Colors: opaque black. (Callers can override.)
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
t.styles[StyleIDDefaultLine] = Style{
FillColor: nil,
StrokeColor: white,
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultCircle] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultPoint] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 2.0,
}
return t
}
// Get returns a style by id.
func (t *StyleTable) Get(id StyleID) (Style, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.styles[id]
if !ok {
return Style{}, false
}
// Defensive copy of slices.
if s.StrokeDashes != nil {
cp := make([]float64, len(s.StrokeDashes))
copy(cp, s.StrokeDashes)
s.StrokeDashes = cp
}
return s, true
}
// AddDerived creates a new style based on baseID with an override applied.
// It returns the new style ID.
func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID {
t.mu.Lock()
defer t.mu.Unlock()
base, ok := t.styles[baseID]
if !ok {
panic("StyleTable.AddDerived: unknown base style ID")
}
derived := override.Apply(base)
id := t.nextID
t.nextID++
// Defensive copy of slices on store.
if derived.StrokeDashes != nil {
cp := make([]float64, len(derived.StrokeDashes))
copy(cp, derived.StrokeDashes)
derived.StrokeDashes = cp
}
t.styles[id] = derived
return id
}