feat: hit on primitives

This commit is contained in:
IliaDenisov
2026-03-07 19:28:22 +02:00
parent e4b956232f
commit c076347d70
21 changed files with 1167 additions and 165 deletions
+11 -5
View File
@@ -18,7 +18,7 @@ type interactiveRaster struct {
onScrolled func(*fyne.ScrollEvent)
onDragged func(*fyne.DragEvent)
onDragEnd func()
onTapped *fyne.PointEvent
onTapped func(*fyne.PointEvent)
}
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
@@ -40,17 +40,22 @@ func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
// Tapped is a left-click event
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
x, y := int(ev.Position.X), int(ev.Position.Y)
size := r.raster.Size()
if x >= int(size.Width) || y >= int(size.Height) {
if r.onTapped == nil {
return
}
r.onTapped(ev)
}
// TappedSecondary is a right-click event
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyne.Size), onScrolled func(*fyne.ScrollEvent), onDragged func(*fyne.DragEvent), onDragEnd func()) *interactiveRaster {
func newInteractiveRaster(edit *editor, raster *canvas.Raster,
onLayout func(fyne.Size),
onScrolled func(*fyne.ScrollEvent),
onDragged func(*fyne.DragEvent),
onDragEnd func(),
onTapped func(*fyne.PointEvent),
) *interactiveRaster {
r := &interactiveRaster{
// raster: canvas.NewRaster(edit.draw),
raster: raster,
@@ -59,6 +64,7 @@ func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyn
onScrolled: onScrolled,
onDragged: onDragged,
onDragEnd: onDragEnd,
onTapped: onTapped,
}
r.ExtendBaseWidget(r)
return r
+33 -6
View File
@@ -1,6 +1,7 @@
package client
import (
"fmt"
"image"
"image/color"
"math"
@@ -47,6 +48,8 @@ type editor struct {
viewportImg *image.RGBA
viewportW int
viewportH int
hits []world.Hit
}
func (e *editor) CanvasScale() float32 { return e.canvasScale }
@@ -171,7 +174,24 @@ func (e *editor) onDradEnd() {
e.pan.DragEnd()
}
func (e *editor) wheelZoom(stepDelta int) {}
func (e *editor) onTapped(ev *fyne.PointEvent) {
hits, err := e.world.HitTest(e.hits, e.wp, int(ev.Position.X*e.canvasScale), int(ev.Position.Y*e.canvasScale))
if err != nil {
panic(err)
}
m := func(v int) float64 {
return float64(v) / float64(world.SCALE)
}
var coord string
for _, hit := range hits {
if hit.Kind == world.KindLine {
coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
} else {
coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
}
fmt.Println("hit:", hit.ID, "Coord:", coord)
}
}
func (e *editor) InitImage() {
s := fyne.NewSize(292, 292)
@@ -203,6 +223,7 @@ func NewEditor() *editor {
Options: &world.RenderOptions{DisableWrapScroll: false},
},
canvasScale: 1.0,
hits: make([]world.Hit, 5),
}
// Create a drawer with some initial context; real size will be adjusted on first draw.
@@ -213,7 +234,7 @@ func NewEditor() *editor {
return e.draw(wPx, hPx)
})
e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd)
e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
e.pan = NewPanController(e)
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
@@ -241,18 +262,24 @@ func testWorldInit(w *world.World) {
StrokeDashes: new([]float64{10.}),
})
circleStyle := w.AddStyleCircle(world.StyleOverride{
discStyle := 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 {
circleStyle := w.AddStyleCircle(world.StyleOverride{
FillColor: world.TransparentFill(),
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
StrokeWidthPx: new(4.0),
})
if _, err := w.AddCircle(150, 150, 50, world.CircleWithStyleID(discStyle)); err != nil {
panic(err)
}
if _, err := w.AddCircle(150, 299, 30); err != nil {
if _, err := w.AddCircle(150, 299, 30, world.CircleWithStyleID(circleStyle)); err != nil {
panic(err)
}
if _, err := w.AddCircle(299, 150, 30); err != nil {
if _, err := w.AddCircle(299, 150, 30, world.CircleWithStyleID(circleStyle)); err != nil {
panic(err)
}
-1
View File
@@ -5,7 +5,6 @@ go 1.26.0
require (
fyne.io/fyne/v2 v2.7.3
github.com/fogleman/gg v1.3.0
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1
)
-2
View File
@@ -39,8 +39,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
+221
View File
@@ -0,0 +1,221 @@
package world
import (
"sort"
)
// PrimitiveKind identifies primitive types in hit-test results.
type PrimitiveKind uint8
const (
KindLine PrimitiveKind = iota
KindCircle
KindPoint
)
// Hit describes one primitive that matches a hit-test query.
type Hit struct {
ID PrimitiveID
Kind PrimitiveKind
Priority int
StyleID StyleID
// DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort).
// Used for tie-breaking (smaller is better).
DistanceSq u128
// Primitive world coordinates:
// - Point: X,Y set
// - Circle: X,Y,Radius set
// - Line: X1,Y1,X2,Y2 set
X, Y int
Radius int
X1, Y1 int
X2, Y2 int
}
// Default hit slop (in pixels) per primitive type.
const (
DefaultHitSlopLinePx = 6
DefaultHitSlopCirclePx = 6
DefaultHitSlopPointPx = 8
// If a circle's screen radius is below this threshold, treat it as point-like for hit testing.
CirclePointLikeMinRadiusPx = 3
)
// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop.
// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations).
//
// If cap(out) is too small, it returns only the best hits by ranking:
//
// Priority desc, Distance asc, Kind asc, ID asc.
//
// Notes:
// - cursorXPx/cursorYPx are relative to viewport top-left.
// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll).
func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if w.grid == nil || w.rows == 0 || w.cols == 0 {
return nil, errGridNotBuilt
}
zoomFp, err := params.CameraZoomFp()
if err != nil {
return nil, err
}
allowWrap := true
if params.Options != nil && params.Options.DisableWrapScroll {
allowWrap = false
}
// Use clamped camera in no-wrap mode for consistency.
camX := params.CameraXWorldFp
camY := params.CameraYWorldFp
if !allowWrap {
camX, camY = ClampCameraNoWrapViewport(
camX, camY,
params.ViewportWidthPx, params.ViewportHeightPx,
zoomFp,
w.W, w.H,
)
}
// Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera).
worldPerPx := PixelSpanToWorldFixed(1, zoomFp)
offXPx := cursorXPx - params.ViewportWidthPx/2
offYPx := cursorYPx - params.ViewportHeightPx/2
cursorX := camX + offXPx*worldPerPx
cursorY := camY + offYPx*worldPerPx
if allowWrap {
cursorX = wrap(cursorX, w.W)
cursorY = wrap(cursorY, w.H)
} else {
// Clamp cursor into world bounds to avoid weird negative coords in margins.
cursorX = clamp(cursorX, 0, w.W-1)
cursorY = clamp(cursorY, 0, w.H-1)
}
// Compute a conservative search bbox around cursor using max possible slop (px->world).
// We use the maximum of default slops; per-object overrides are handled later.
maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx))
maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp)
minX := cursorX - maxSlopWorld
maxX := cursorX + maxSlopWorld + 1
minY := cursorY - maxSlopWorld
maxY := cursorY + maxSlopWorld + 1
var rects []Rect
if allowWrap {
rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
} else {
// Clamp to world.
minX = clamp(minX, 0, w.W)
maxX = clamp(maxX, 0, w.W)
minY = clamp(minY, 0, w.H)
maxY = clamp(maxY, 0, w.H)
if maxX <= minX || maxY <= minY {
return out[:0], nil
}
rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}}
}
// Gather candidates from grid cells, dedupe by ID.
cand := make(map[PrimitiveID]MapItem, 32)
for _, r := range rects {
colStart := w.worldToCellX(r.minX)
colEnd := w.worldToCellX(r.maxX - 1)
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
for row := rowStart; row <= rowEnd; row++ {
for col := colStart; col <= colEnd; col++ {
cell := w.grid[row][col]
for _, it := range cell {
cand[it.ID()] = it
}
}
}
}
// Use caller buffer as backing store; keep only best cap(out) hits.
out = out[:0]
limit := cap(out)
for _, it := range cand {
h, ok := w.hitOne(it, cursorX, cursorY, zoomFp, allowWrap)
if !ok {
continue
}
if limit == 0 {
// Caller provided zero-cap buffer; cannot store anything.
continue
}
if len(out) < limit {
out = append(out, h)
continue
}
// Replace the worst hit if the new one is better.
worstIdx := 0
for i := 1; i < len(out); i++ {
if hitLess(out[worstIdx], out[i]) {
worstIdx = i // out[i] is worse than out[worstIdx]
}
}
if hitLess(h, out[worstIdx]) {
out[worstIdx] = h
}
}
// Sort final hits by best-first order.
sort.Slice(out, func(i, j int) bool {
return hitLess(out[i], out[j])
})
return out, nil
}
// hitLess orders hits by:
// Priority desc, DistanceSq asc, Kind asc, ID asc.
func hitLess(a, b Hit) bool {
if a.Priority != b.Priority {
return a.Priority > b.Priority
}
if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 {
return c < 0
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.ID < b.ID
}
func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) {
switch v := it.(type) {
case Point:
return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Circle:
style, ok := w.styles.Get(v.StyleID)
if !ok {
// Unknown style should not happen; treat as no-hit rather than panic.
return Hit{}, false
}
return hitCircle(v, style, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Line:
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
default:
panic("HitTest: unknown map item type")
}
}
+74
View File
@@ -0,0 +1,74 @@
package world
import "math/bits"
// u128 is an unsigned 128-bit integer for safe squared comparisons.
type u128 struct{ hi, lo uint64 }
func u128FromMul64(a, b uint64) u128 {
hi, lo := bits.Mul64(a, b)
return u128{hi: hi, lo: lo}
}
func u128Add(a, b u128) u128 {
lo := a.lo + b.lo
hi := a.hi + b.hi
if lo < a.lo {
hi++
}
return u128{hi: hi, lo: lo}
}
func u128Cmp(a, b u128) int {
if a.hi < b.hi {
return -1
}
if a.hi > b.hi {
return 1
}
if a.lo < b.lo {
return -1
}
if a.lo > b.lo {
return 1
}
return 0
}
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}
func sqU128Int64(x int64) u128 {
u := uint64(abs64(x))
return u128FromMul64(u, u)
}
func distSqU128(dx, dy int64) u128 {
return u128Add(sqU128Int64(dx), sqU128Int64(dy))
}
// shortestTorusDelta returns the shortest signed delta from a->b on a torus axis of size.
// It is deterministic in tie cases (size even, exactly half): chooses negative direction.
func shortestTorusDelta(a, b, size int) int64 {
d := int64(b - a)
s := int64(size)
half := s / 2
// Normalize d into (-s, s).
d = d % s
if d <= -half {
d += s
} else if d > half {
d -= s
}
// Tie case when size even and d == +half: choose -half.
if s%2 == 0 && d == half {
d = -half
}
return d
}
+247
View File
@@ -0,0 +1,247 @@
package world
import (
"image/color"
"math/bits"
)
func effectiveHitSlopPx(hitSlopPx int, def int) int {
if hitSlopPx > 0 {
return hitSlopPx
}
return def
}
func alphaNonZero(c color.Color) bool {
if c == nil {
return false
}
_, _, _, a := c.RGBA()
return a != 0
}
func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(p.X, cx, worldW)
dy = shortestTorusDelta(p.Y, cy, worldH)
} else {
dx = int64(cx - p.X)
dy = int64(cy - p.Y)
}
// Point is treated as a small disc: dist <= slop.
ds := distSqU128(dx, dy)
rs := sqU128Int64(int64(slopW))
if u128Cmp(ds, rs) <= 0 {
return Hit{
ID: p.Id,
Kind: KindPoint,
Priority: p.Priority,
StyleID: p.StyleID,
DistanceSq: ds,
X: p.X,
Y: p.Y,
}, true
}
return Hit{}, false
}
func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
fillVisible := alphaNonZero(style.FillColor)
// Determine if circle is point-like at current zoom.
// IMPORTANT: point-like disc behavior applies only for filled circles.
rPx := worldSpanFixedToCanvasPx(c.Radius, zoomFp)
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(c.X, cx, worldW)
dy = shortestTorusDelta(c.Y, cy, worldH)
} else {
dx = int64(cx - c.X)
dy = int64(cy - c.Y)
}
ds := distSqU128(dx, dy)
// Filled + point-like: treat as a disc with minimum visible radius + slop.
if pointLike {
// Treat as a disc with minimum visible radius in px.
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
effR := minRW
if c.Radius > effR {
effR = c.Radius
}
r := effR + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: c.Radius,
}, true
}
return Hit{}, false
}
// Filled circle: hit-test by disc (surface).
if fillVisible {
r := c.Radius + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: c.Radius,
}, true
}
return Hit{}, false
}
// Stroke-only circle: ring hit, but NEVER at exact center.
// For very small circles, expand the effective radius to a minimum visible size
// so that ring selection remains practical, while still excluding center.
effR := c.Radius
if rPx < CirclePointLikeMinRadiusPx {
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
if minRW > effR {
effR = minRW
}
}
low := effR - slopW
// IMPORTANT: center must not hit for stroke-only circles.
if low < 1 {
low = 1
}
high := effR + slopW
lowSq := sqU128Int64(int64(low))
highSq := sqU128Int64(int64(high))
if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: c.Radius,
}, true
}
return Hit{}, false
}
func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
// For wrap: compare against torus-shortest representation (same as rendering).
// We test all segments produced by torusShortestLineSegments and take the best (min distance).
segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
if allowWrap {
segs = torusShortestLineSegments(l, worldW, worldH)
}
best := Hit{}
found := false
for _, s := range segs {
ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2))
// Check ds <= slopW^2
if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 {
h := Hit{
ID: l.Id,
Kind: KindLine,
Priority: l.Priority,
StyleID: l.StyleID,
DistanceSq: ds,
X1: l.X1,
Y1: l.Y1,
X2: l.X2,
Y2: l.Y2,
}
if !found || hitLess(h, best) {
best = h
found = true
}
}
}
return best, found
}
// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons.
func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 {
abx := bx - ax
aby := by - ay
apx := px - ax
apy := py - ay
// Degenerate segment => distance to point A.
if abx == 0 && aby == 0 {
return distSqU128(apx, apy)
}
dot := apx*abx + apy*aby
if dot <= 0 {
return distSqU128(apx, apy)
}
abLen2 := abx*abx + aby*aby
if dot >= abLen2 {
bpx := px - bx
bpy := py - by
return distSqU128(bpx, bpy)
}
// Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers.
// Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain
// would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use
// dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64.
//
// cross = AP x AB
cross := apx*aby - apy*abx
// cross^2 fits in u128, abLen2 fits in int64.
c2 := sqU128Int64(cross)
return u128DivByU64(c2, uint64(abLen2))
}
// u128DivByU64 returns floor(a / d) where d>0, producing u128 result.
// Here we only need it for tie-breaking (monotonic).
func u128DivByU64(a u128, d uint64) u128 {
if d == 0 {
panic("u128DivByU64: divide by zero")
}
// Simple long division for 128/64 -> 128 quotient (but high part will be small here).
// We compute using two-step: divide high then combine.
qHi := a.hi / d
rHi := a.hi % d
// Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d.
// Use bits.Div64 for (hi, lo)/d.
qLo, _ := bits.Div64(rHi, a.lo, d)
return u128{hi: qHi, lo: qLo}
}
+152
View File
@@ -0,0 +1,152 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
)
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Build index once renderer state is initialized.
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
// Add overlapping objects near center.
idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
require.NoError(t, err)
idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
require.NoError(t, err)
idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200))
require.NoError(t, err)
// Force index rebuild from last state (Add already does it, but keep explicit).
w.Reindex()
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, 50, 50) // center of viewport
require.NoError(t, err)
// Should find all three, best first (priority desc).
require.Len(t, hits, 3)
require.Equal(t, idCircle, hits[0].ID)
require.Equal(t, idPoint, hits[1].ID)
require.Equal(t, idLine, hits[2].ID)
}
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
_, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
_, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200))
w.Reindex()
// Only room for 1 hit => must keep the best (highest priority).
buf := make([]Hit, 0, 1)
hits, err := w.HitTest(buf, &params, 50, 50)
require.NoError(t, err)
require.Len(t, hits, 1)
require.Equal(t, idCircle, hits[0].ID)
}
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 25,
MarginYPx: 25,
CameraXWorldFp: -100000, // invalid camera, should be clamped
CameraYWorldFp: -100000,
CameraZoom: 1.0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
_, err := w.AddPoint(0.0, 0.0, PointWithPriority(100))
require.NoError(t, err)
w.Reindex()
// Tap near top-left of viewport should still map to world and find the point.
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, 0, 0)
require.NoError(t, err)
require.NotEmpty(t, hits)
}
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
// Stroke-only circle: FillColor alpha=0 => ring mode.
ov := StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{A: 255},
}
strokeStyle := w.AddStyleCircle(ov)
_, err := w.AddCircle(5.0, 5.0, 2.0,
CircleWithStyleID(strokeStyle),
CircleWithPriority(100),
)
require.NoError(t, err)
w.Reindex()
buf := make([]Hit, 0, 8)
// Center must NOT hit.
hits, err := w.HitTest(buf, &params, 50, 50)
require.NoError(t, err)
require.Empty(t, hits)
// Near ring should hit. For small circles we use a minimum visible ring radius (3px).
// So tapping at +3px from center should be within ring+slop.
hits, err = w.HitTest(buf, &params, 50+3, 50)
require.NoError(t, err)
require.NotEmpty(t, hits)
require.Equal(t, KindCircle, hits[0].Kind)
}
+43 -44
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
@@ -17,7 +16,7 @@ func newTestWorld(wReal, hReal int) *World {
return NewWorld(wReal, hReal)
}
func countObjectInGrid(g *World, id uuid.UUID) int {
func countObjectInGrid(g *World, id PrimitiveID) int {
count := 0
for row := range g.grid {
for col := range g.grid[row] {
@@ -31,7 +30,7 @@ func countObjectInGrid(g *World, id uuid.UUID) int {
return count
}
func hasObjectInCell(g *World, row, col int, id uuid.UUID) bool {
func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool {
for _, item := range g.grid[row][col] {
if item.ID() == id {
return true
@@ -283,7 +282,7 @@ func TestIndexPoint(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
p := Point{
Id: id,
X: 150 * SCALE,
@@ -300,7 +299,7 @@ func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
p := Point{
Id: id,
X: -1,
@@ -317,7 +316,7 @@ func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
c := Circle{
Id: id,
X: 50 * SCALE,
@@ -344,7 +343,7 @@ func TestIndexCircle_NoWrap(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
c := Circle{
Id: id,
X: 300 * SCALE,
@@ -364,7 +363,7 @@ func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
c := Circle{
Id: id,
X: 300 * SCALE,
@@ -385,7 +384,7 @@ func TestIndexLine_HorizontalWrap(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 590 * SCALE,
@@ -405,7 +404,7 @@ func TestIndexLine_VerticalWrap(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 200 * SCALE,
@@ -424,7 +423,7 @@ func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 590 * SCALE,
@@ -443,7 +442,7 @@ func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 100 * SCALE,
@@ -465,7 +464,7 @@ func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 200 * SCALE,
@@ -487,7 +486,7 @@ func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 250 * SCALE,
@@ -506,7 +505,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
l := Line{
Id: id,
X1: 200 * SCALE,
@@ -523,7 +522,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) {
require.False(t, hasObjectInCell(g, 1, 4, id))
}
func collectOccupiedCells(g *World, id uuid.UUID) []gridCell {
func collectOccupiedCells(g *World, id PrimitiveID) []gridCell {
var cells []gridCell
for row := range g.grid {
for col := range g.grid[row] {
@@ -548,7 +547,7 @@ func allGridCells(rows, cols int) []gridCell {
return cells
}
func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []gridCell) {
func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) {
t.Helper()
got := collectOccupiedCells(g, id)
@@ -557,8 +556,8 @@ func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []g
t,
want,
got,
"unexpected indexed cells for object %s",
id.String(),
"unexpected indexed cells for object %d",
id,
)
}
@@ -577,7 +576,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Point{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 150 * SCALE,
Y: 250 * SCALE,
},
@@ -591,7 +590,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Point{
Id: uuid.New(),
Id: PrimitiveID(1),
X: -1,
Y: -1,
},
@@ -605,7 +604,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Point{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 600 * SCALE,
Y: 600 * SCALE,
},
@@ -619,7 +618,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Point{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 200 * SCALE,
Y: 300 * SCALE,
},
@@ -656,7 +655,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 300 * SCALE,
Y: 300 * SCALE,
Radius: 50 * SCALE,
@@ -674,7 +673,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 50 * SCALE,
Y: 50 * SCALE,
Radius: 75 * SCALE,
@@ -697,7 +696,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 575 * SCALE,
Y: 300 * SCALE,
Radius: 50 * SCALE,
@@ -715,7 +714,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 300 * SCALE,
Y: 575 * SCALE,
Radius: 50 * SCALE,
@@ -733,7 +732,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 300 * SCALE,
Y: 300 * SCALE,
Radius: 400 * SCALE,
@@ -746,7 +745,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Circle{
Id: uuid.New(),
Id: PrimitiveID(1),
X: 300 * SCALE,
Y: 300 * SCALE,
Radius: 100 * SCALE, // bbox [200, 400) x [200, 400)
@@ -787,7 +786,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 100 * SCALE,
Y1: 200 * SCALE,
X2: 300 * SCALE,
@@ -805,7 +804,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 200 * SCALE,
Y1: 100 * SCALE,
X2: 200 * SCALE,
@@ -823,7 +822,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 590 * SCALE,
Y1: 200 * SCALE,
X2: 10 * SCALE,
@@ -840,7 +839,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 200 * SCALE,
Y1: 590 * SCALE,
X2: 200 * SCALE,
@@ -857,7 +856,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 590 * SCALE,
Y1: 590 * SCALE,
X2: 10 * SCALE,
@@ -876,7 +875,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 250 * SCALE,
Y1: 350 * SCALE,
X2: 250 * SCALE,
@@ -892,7 +891,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 200 * SCALE,
Y1: 100 * SCALE,
X2: 400 * SCALE,
@@ -910,7 +909,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 100 * SCALE,
Y1: 100 * SCALE,
X2: 300 * SCALE,
@@ -931,7 +930,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
worldH: 600,
cellSize: 100 * SCALE,
item: Line{
Id: uuid.New(),
Id: PrimitiveID(1),
X1: 600 * SCALE,
Y1: 100 * SCALE,
X2: 0,
@@ -960,9 +959,9 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) {
g := newTestWorld(600, 400)
pID := uuid.New()
cID := uuid.New()
lID := uuid.New()
pID := PrimitiveID(1)
cID := PrimitiveID(2)
lID := PrimitiveID(3)
g.objects[pID] = Point{
Id: pID,
@@ -1007,7 +1006,7 @@ func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T)
func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) {
g := newTestWorld(600, 600)
id := uuid.New()
id := PrimitiveID(1)
g.objects[id] = Circle{
Id: id,
X: 300 * SCALE,
@@ -1041,7 +1040,7 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) {
g := newTestWorld(600, 600)
g.resetGrid(100 * SCALE)
id := uuid.New()
id := PrimitiveID(1)
p := Point{
Id: id,
X: 100 * SCALE,
@@ -1051,5 +1050,5 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) {
g.indexObject(p)
got := collectOccupiedCells(g, id)
require.NotEmpty(t, got, fmt.Sprintf("object %s should occupy at least one cell", id.String()))
require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id))
}
+18
View File
@@ -10,6 +10,8 @@ type PointOptions struct {
StyleID StyleID
Override StyleOverride
HitSlopPx int
hasStyleID bool
}
@@ -44,6 +46,10 @@ func PointWithStyleOverride(ov StyleOverride) PointOpt {
}
}
func PointWithHitSlopPx(px int) PointOpt {
return func(o *PointOptions) { o.HitSlopPx = px }
}
type CircleOpt func(*CircleOptions)
type CircleOptions struct {
@@ -51,6 +57,8 @@ type CircleOptions struct {
StyleID StyleID
Override StyleOverride
HitSlopPx int
hasStyleID bool
}
@@ -81,6 +89,10 @@ func CircleWithStyleOverride(ov StyleOverride) CircleOpt {
}
}
func CircleWithHitSlopPx(px int) CircleOpt {
return func(o *CircleOptions) { o.HitSlopPx = px }
}
type LineOpt func(*LineOptions)
type LineOptions struct {
@@ -88,6 +100,8 @@ type LineOptions struct {
StyleID StyleID
Override StyleOverride
HitSlopPx int
hasStyleID bool
}
@@ -117,3 +131,7 @@ func LineWithStyleOverride(ov StyleOverride) LineOpt {
o.Override = ov
}
}
func LineWithHitSlopPx(px int) LineOpt {
return func(o *LineOptions) { o.HitSlopPx = px }
}
+23 -11
View File
@@ -1,28 +1,32 @@
package world
import (
"github.com/google/uuid"
)
// PrimitiveID is a compact stable identifier for primitives stored in the World.
// It is allocated by the World and may be reused after deletion (free-list).
type PrimitiveID uint32
// MapItem is the common interface implemented by all world primitives.
type MapItem interface {
ID() uuid.UUID
ID() PrimitiveID
}
// Point is a point primitive in fixed-point world coordinates.
type Point struct {
Id uuid.UUID
Id PrimitiveID
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
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Line is a line segment primitive in fixed-point world coordinates.
type Line struct {
Id uuid.UUID
Id PrimitiveID
X1, Y1 int
X2, Y2 int
@@ -30,28 +34,36 @@ type Line struct {
Priority int
// StyleID references a resolved style in the world's style table.
StyleID StyleID
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// Circle is a circle primitive in fixed-point world coordinates.
type Circle struct {
Id uuid.UUID
Id PrimitiveID
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
// HitSlopPx expands hit-test radius in screen pixels (per-object override).
// 0 means "use primitive default".
HitSlopPx int
}
// ID returns the point identifier.
func (p Point) ID() uuid.UUID { return p.Id }
func (p Point) ID() PrimitiveID { return p.Id }
// ID returns the line identifier.
func (l Line) ID() uuid.UUID { return l.Id }
func (l Line) ID() PrimitiveID { return l.Id }
// ID returns the circle identifier.
func (c Circle) ID() uuid.UUID { return c.Id }
func (c Circle) ID() PrimitiveID { return c.Id }
// MinX returns the minimum X endpoint coordinate of the line.
func (l Line) MinX() int { return min(l.X1, l.X2) }
+3 -5
View File
@@ -2,16 +2,14 @@ package world
import (
"testing"
"github.com/google/uuid"
)
func TestPrimitiveIDs(t *testing.T) {
t.Parallel()
id1 := uuid.New()
id2 := uuid.New()
id3 := uuid.New()
id1 := PrimitiveID(1)
id2 := PrimitiveID(2)
id3 := PrimitiveID(3)
p := Point{Id: id1}
l := Line{Id: id2}
+98
View File
@@ -1,6 +1,7 @@
package world
import (
"image/color"
"testing"
"github.com/stretchr/testify/require"
@@ -197,3 +198,100 @@ func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
// Center must be at (9,9) only, no (-1,*) or (*,-1).
require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
}
func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
sw := 4.0
circleStyle := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0}, // explicitly transparent
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
StrokeWidthPx: &sw,
})
_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), 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()
iAdd := indexOfFirstName(cmds, "AddCircle")
require.NotEqual(t, -1, iAdd)
// After AddCircle we must see Stroke (not Fill).
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds)))
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds)))
require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()")
require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible")
}
func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
sw := 2.0
styleID := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255},
StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
StrokeWidthPx: &sw,
})
_, err := w.AddCircle(5, 5, 2, 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()
iAdd := indexOfFirstName(cmds, "AddCircle")
require.NotEqual(t, -1, iAdd)
iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds)))
iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds)))
require.NotEqual(t, -1, iFill, "expected Fill() for visible fill")
require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke")
require.Less(t, iFill, iStroke, "Stroke must be last when both are visible")
}
+2 -18
View File
@@ -2,8 +2,6 @@ package world
import (
"sort"
"github.com/google/uuid"
)
// drawKind is used only for stable tie-breaking when priorities are equal.
@@ -18,7 +16,7 @@ const (
type drawItem struct {
kind drawKind
priority int
id uuid.UUID
id PrimitiveID
styleID StyleID
// Exactly one of these is set.
@@ -122,7 +120,7 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
if a.kind != b.kind {
return a.kind < b.kind
}
return uuidLess(a.id, b.id)
return a.id < b.id
})
drawer.Save()
@@ -149,17 +147,3 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
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
}
+14 -5
View File
@@ -29,10 +29,14 @@ func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td Tile
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 {
fill := alphaNonZero(lastStyle.FillColor)
stroke := alphaNonZero(lastStyle.StrokeColor)
if fill {
drawer.Fill()
} else if lastStyle.StrokeColor != nil {
}
if stroke {
// Stroke must be last when both are present.
drawer.Stroke()
}
}
@@ -58,9 +62,14 @@ func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td Til
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
if lastStyle.FillColor != nil {
fill := alphaNonZero(lastStyle.FillColor)
stroke := alphaNonZero(lastStyle.StrokeColor)
if fill {
drawer.Fill()
} else if lastStyle.StrokeColor != nil {
}
if stroke {
// Stroke must be last when both are present.
drawer.Stroke()
}
}
+1 -3
View File
@@ -2,8 +2,6 @@ package world
import (
"errors"
"github.com/google/uuid"
)
var (
@@ -59,7 +57,7 @@ func (w *World) collectCandidatesForTile(r Rect) []MapItem {
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
seen := make(map[uuid.UUID]struct{})
seen := make(map[PrimitiveID]struct{})
result := make([]MapItem, 0)
for row := rowStart; row <= rowEnd; row++ {
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"sort"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
@@ -664,7 +663,7 @@ func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) {
if len(td.Candidates) == 0 {
continue
}
seen := map[uuid.UUID]struct{}{}
seen := map[PrimitiveID]struct{}{}
for _, it := range td.Candidates {
_, ok := seen[it.ID()]
require.False(t, ok, "candidate duplicated within a tile")
+6
View File
@@ -25,6 +25,12 @@ const (
DefaultPriorityPoint = 300
)
var (
transparentColor color.Color = &color.RGBA{A: 0}
)
func TransparentFill() color.Color { return transparentColor }
// 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.
+143 -21
View File
@@ -3,15 +3,22 @@ package world
import (
"errors"
"fmt"
"github.com/google/uuid"
)
var (
errBadCoordinate = errors.New("invalid coordinates")
errBadRadius = errors.New("invalid radius")
errNoSuchObject = errors.New("no such object")
errIDExhausted = errors.New("primitive id exhausted")
)
type indexState struct {
initialized bool
viewportW int
viewportH int
zoomFp int
}
// World stores torus world dimensions, all registered objects,
// and the grid-based spatial index built for the current viewport settings.
type World struct {
@@ -19,9 +26,18 @@ type World struct {
grid [][][]MapItem
cellSize int
rows, cols int
objects map[uuid.UUID]MapItem
renderState rendererIncrementalState
objects map[PrimitiveID]MapItem
styles *StyleTable
// PrimitiveID allocator state.
nextID PrimitiveID
freeIDs []PrimitiveID
// Index dirty flag for add/remove updates.
indexDirty bool
index indexState
renderState rendererIncrementalState
}
// NewWorld constructs a new world with the given real dimensions.
@@ -34,11 +50,36 @@ func NewWorld(width, height int) *World {
W: width * SCALE,
H: height * SCALE,
cellSize: 1,
objects: make(map[uuid.UUID]MapItem),
objects: make(map[PrimitiveID]MapItem),
styles: NewStyleTable(),
nextID: 1, // 0 is reserved as "invalid"
}
}
// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter.
// It returns an error if the ID space is exhausted.
func (g *World) allocID() (PrimitiveID, error) {
if n := len(g.freeIDs); n > 0 {
id := g.freeIDs[n-1]
g.freeIDs = g.freeIDs[:n-1]
return id, nil
}
if g.nextID == PrimitiveID(^uint32(0)) {
return 0, errIDExhausted
}
id := g.nextID
g.nextID++
return id, nil
}
// freeID returns an id back to the pool. It is safe to call only after the object is removed.
func (g *World) freeID(id PrimitiveID) {
if id == 0 {
return
}
g.freeIDs = append(g.freeIDs, id)
}
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
// lies inside the world bounds: [0, W) x [0, H).
func (g *World) checkCoordinate(xf, yf int) bool {
@@ -63,14 +104,52 @@ func (g *World) AddStylePoint(override StyleOverride) StyleID {
return g.styles.AddDerived(StyleIDDefaultPoint, override)
}
// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown.
// It marks the spatial index dirty and triggers an autonomous rebuild if possible.
func (g *World) Remove(id PrimitiveID) error {
if _, ok := g.objects[id]; !ok {
return errNoSuchObject
}
delete(g.objects, id)
g.freeID(id)
g.indexDirty = true
g.rebuildIndexFromLastState()
return nil
}
// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state
// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true.
func (g *World) Reindex() {
g.indexDirty = true
g.rebuildIndexFromLastState()
}
// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp
// from renderer state. If that state is not initialized, it does nothing.
func (g *World) rebuildIndexFromLastState() {
if !g.indexDirty {
return
}
if !g.index.initialized {
return
}
if g.index.viewportW <= 0 || g.index.viewportH <= 0 || g.index.zoomFp <= 0 {
return
}
g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp)
g.indexDirty = false
}
// AddPoint validates and stores a point primitive in the world.
// The input coordinates are given in real world units and are converted
// to fixed-point before validation.
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
return 0, errBadCoordinate
}
o := defaultPointOptions()
@@ -81,29 +160,38 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
}
styleID := g.resolvePointStyleID(o)
id := uuid.New()
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Point{
Id: id,
X: xf,
Y: yf,
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
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, opts ...CircleOpt) (uuid.UUID, error) {
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return uuid.Nil, errBadCoordinate
return 0, errBadCoordinate
}
if r < 0 {
return uuid.Nil, errBadRadius
return 0, errBadRadius
}
o := defaultCircleOptions()
@@ -114,7 +202,11 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error)
}
styleID := g.resolveCircleStyleID(o)
id := uuid.New()
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Circle{
Id: id,
X: xf,
@@ -122,24 +214,29 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error)
Radius: fixedPoint(r),
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
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, opts ...LineOpt) (uuid.UUID, error) {
func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) {
x1f := fixedPoint(x1)
y1f := fixedPoint(y1)
x2f := fixedPoint(x2)
y2f := fixedPoint(y2)
if ok := g.checkCoordinate(x1f, y1f); !ok {
return uuid.Nil, errBadCoordinate
return 0, errBadCoordinate
}
if ok := g.checkCoordinate(x2f, y2f); !ok {
return uuid.Nil, errBadCoordinate
return 0, errBadCoordinate
}
o := defaultLineOptions()
@@ -150,7 +247,11 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err
}
styleID := g.resolveLineStyleID(o)
id := uuid.New()
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Line{
Id: id,
X1: x1f,
@@ -159,7 +260,12 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err
Y2: y2f,
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
return id, nil
}
@@ -206,6 +312,10 @@ func (g *World) worldToCellY(y int) int {
// resetGrid recreates the spatial grid with the given cell size
// and clears all previous indexing state.
func (g *World) resetGrid(cellSize int) {
if cellSize <= 0 {
panic("resetGrid: invalid cell size")
}
g.cellSize = cellSize
g.cols = ceilDiv(g.W, g.cellSize)
g.rows = ceilDiv(g.H, g.cellSize)
@@ -277,12 +387,24 @@ func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
}
}
// IndexOnViewportChange rebuilds the grid for a new viewport size and zoom.
// The zoom is provided by the UI as a real multiplier and is converted
// to fixed-point inside the function.
// IndexOnViewportChange is called when UI window sizes are changed.
// cameraZoom is float64, converted inside world to fixed-point.
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom)
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp)
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
// Remember params for autonomous reindex after Add/Remove.
g.index.initialized = true
g.index.viewportW = viewportWidthPx
g.index.viewportH = viewportHeightPx
g.index.zoomFp = zoomFp
g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
g.indexDirty = false
}
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
cellsAcrossMin := 8
visibleMin := min(worldWidth, worldHeight)
+37
View File
@@ -0,0 +1,37 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWorldPrimitiveID_ReusesFreedIDs(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
id1, err := w.AddPoint(1, 1)
require.NoError(t, err)
id2, err := w.AddPoint(2, 2)
require.NoError(t, err)
require.NotEqual(t, id1, id2)
require.NoError(t, w.Remove(id1))
id3, err := w.AddPoint(3, 3)
require.NoError(t, err)
// LIFO free-list: id1 should be reused.
require.Equal(t, id1, id3)
}
func TestWorldRemove_UnknownID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
err := w.Remove(12345)
require.ErrorIs(t, err, errNoSuchObject)
}
+15 -17
View File
@@ -3,8 +3,6 @@ package world
import (
"errors"
"testing"
"github.com/google/uuid"
)
func newIndexedTestWorld() *World {
@@ -13,7 +11,7 @@ func newIndexedTestWorld() *World {
return w
}
func cellHasOnlyID(t *testing.T, w *World, row, col int, want uuid.UUID) {
func cellHasOnlyID(t *testing.T, w *World, row, col int, want PrimitiveID) {
t.Helper()
cell := w.grid[row][col]
@@ -33,7 +31,7 @@ func cellIsEmpty(t *testing.T, w *World, row, col int) {
}
}
func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} {
func occupiedCellsByID(w *World, id PrimitiveID) map[[2]int]struct{} {
result := make(map[[2]int]struct{})
for row := range w.grid {
@@ -49,7 +47,7 @@ func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} {
return result
}
func assertOccupiedCells(t *testing.T, w *World, id uuid.UUID, want ...[2]int) {
func assertOccupiedCells(t *testing.T, w *World, id PrimitiveID, want ...[2]int) {
t.Helper()
got := occupiedCellsByID(w, id)
@@ -375,7 +373,7 @@ func TestIndexObjectPoint(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
p := Point{Id: id, X: 2500, Y: 4500}
w.indexObject(p)
@@ -389,7 +387,7 @@ func TestIndexObjectCircleWithoutWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900}
w.indexObject(c)
@@ -404,7 +402,7 @@ func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
c := Circle{Id: id, X: 500, Y: 500, Radius: 900}
w.indexObject(c)
@@ -421,7 +419,7 @@ func TestIndexObjectCircleCoversWholeWorld(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000}
w.indexObject(c)
@@ -440,7 +438,7 @@ func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000}
w.indexObject(l)
@@ -456,7 +454,7 @@ func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000}
w.indexObject(l)
@@ -472,7 +470,7 @@ func TestIndexObjectLineWrapsAcrossX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000}
w.indexObject(l)
@@ -487,7 +485,7 @@ func TestIndexObjectLineWrapsAcrossY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000}
w.indexObject(l)
@@ -502,7 +500,7 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
id := PrimitiveID(1)
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000}
w.indexObject(l)
@@ -515,10 +513,10 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
}
type unknown struct {
id uuid.UUID
id PrimitiveID
}
func (u unknown) ID() uuid.UUID {
func (u unknown) ID() PrimitiveID {
return u.id
}
@@ -533,5 +531,5 @@ func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) {
}
}()
w.indexObject(unknown{id: uuid.New()})
w.indexObject(unknown{id: PrimitiveID(1)})
}