feat: hit on primitives
This commit is contained in:
+11
-5
@@ -18,7 +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
|
onTapped func(*fyne.PointEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
||||||
@@ -40,17 +40,22 @@ func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
|
|||||||
|
|
||||||
// Tapped is a left-click event
|
// Tapped is a left-click event
|
||||||
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
||||||
x, y := int(ev.Position.X), int(ev.Position.Y)
|
if r.onTapped == nil {
|
||||||
size := r.raster.Size()
|
|
||||||
if x >= int(size.Width) || y >= int(size.Height) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.onTapped(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TappedSecondary is a right-click event
|
// TappedSecondary is a right-click event
|
||||||
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
|
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{
|
r := &interactiveRaster{
|
||||||
// raster: canvas.NewRaster(edit.draw),
|
// raster: canvas.NewRaster(edit.draw),
|
||||||
raster: raster,
|
raster: raster,
|
||||||
@@ -59,6 +64,7 @@ func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyn
|
|||||||
onScrolled: onScrolled,
|
onScrolled: onScrolled,
|
||||||
onDragged: onDragged,
|
onDragged: onDragged,
|
||||||
onDragEnd: onDragEnd,
|
onDragEnd: onDragEnd,
|
||||||
|
onTapped: onTapped,
|
||||||
}
|
}
|
||||||
r.ExtendBaseWidget(r)
|
r.ExtendBaseWidget(r)
|
||||||
return r
|
return r
|
||||||
|
|||||||
+33
-6
@@ -1,6 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"math"
|
"math"
|
||||||
@@ -47,6 +48,8 @@ type editor struct {
|
|||||||
viewportImg *image.RGBA
|
viewportImg *image.RGBA
|
||||||
viewportW int
|
viewportW int
|
||||||
viewportH int
|
viewportH int
|
||||||
|
|
||||||
|
hits []world.Hit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *editor) CanvasScale() float32 { return e.canvasScale }
|
func (e *editor) CanvasScale() float32 { return e.canvasScale }
|
||||||
@@ -171,7 +174,24 @@ func (e *editor) onDradEnd() {
|
|||||||
e.pan.DragEnd()
|
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() {
|
func (e *editor) InitImage() {
|
||||||
s := fyne.NewSize(292, 292)
|
s := fyne.NewSize(292, 292)
|
||||||
@@ -203,6 +223,7 @@ func NewEditor() *editor {
|
|||||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||||
},
|
},
|
||||||
canvasScale: 1.0,
|
canvasScale: 1.0,
|
||||||
|
hits: make([]world.Hit, 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a drawer with some initial context; real size will be adjusted on first draw.
|
// 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)
|
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)
|
e.pan = NewPanController(e)
|
||||||
|
|
||||||
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
|
// 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.}),
|
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},
|
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)
|
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)
|
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)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.7.3
|
fyne.io/fyne/v2 v2.7.3
|
||||||
github.com/fogleman/gg v1.3.0
|
github.com/fogleman/gg v1.3.0
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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/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 h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
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 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
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=
|
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 50+3, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, hits)
|
||||||
|
require.Equal(t, KindCircle, hits[0].Kind)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ func newTestWorld(wReal, hReal int) *World {
|
|||||||
return NewWorld(wReal, hReal)
|
return NewWorld(wReal, hReal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func countObjectInGrid(g *World, id uuid.UUID) int {
|
func countObjectInGrid(g *World, id PrimitiveID) int {
|
||||||
count := 0
|
count := 0
|
||||||
for row := range g.grid {
|
for row := range g.grid {
|
||||||
for col := range g.grid[row] {
|
for col := range g.grid[row] {
|
||||||
@@ -31,7 +30,7 @@ func countObjectInGrid(g *World, id uuid.UUID) int {
|
|||||||
return count
|
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] {
|
for _, item := range g.grid[row][col] {
|
||||||
if item.ID() == id {
|
if item.ID() == id {
|
||||||
return true
|
return true
|
||||||
@@ -283,7 +282,7 @@ func TestIndexPoint(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
p := Point{
|
p := Point{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 150 * SCALE,
|
X: 150 * SCALE,
|
||||||
@@ -300,7 +299,7 @@ func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
p := Point{
|
p := Point{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: -1,
|
X: -1,
|
||||||
@@ -317,7 +316,7 @@ func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
c := Circle{
|
c := Circle{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 50 * SCALE,
|
X: 50 * SCALE,
|
||||||
@@ -344,7 +343,7 @@ func TestIndexCircle_NoWrap(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
c := Circle{
|
c := Circle{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
@@ -364,7 +363,7 @@ func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
c := Circle{
|
c := Circle{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
@@ -385,7 +384,7 @@ func TestIndexLine_HorizontalWrap(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 590 * SCALE,
|
X1: 590 * SCALE,
|
||||||
@@ -405,7 +404,7 @@ func TestIndexLine_VerticalWrap(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
@@ -424,7 +423,7 @@ func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 590 * SCALE,
|
X1: 590 * SCALE,
|
||||||
@@ -443,7 +442,7 @@ func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 100 * SCALE,
|
X1: 100 * SCALE,
|
||||||
@@ -465,7 +464,7 @@ func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
@@ -487,7 +486,7 @@ func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 250 * SCALE,
|
X1: 250 * SCALE,
|
||||||
@@ -506,7 +505,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
l := Line{
|
l := Line{
|
||||||
Id: id,
|
Id: id,
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
@@ -523,7 +522,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) {
|
|||||||
require.False(t, hasObjectInCell(g, 1, 4, id))
|
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
|
var cells []gridCell
|
||||||
for row := range g.grid {
|
for row := range g.grid {
|
||||||
for col := range g.grid[row] {
|
for col := range g.grid[row] {
|
||||||
@@ -548,7 +547,7 @@ func allGridCells(rows, cols int) []gridCell {
|
|||||||
return cells
|
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()
|
t.Helper()
|
||||||
|
|
||||||
got := collectOccupiedCells(g, id)
|
got := collectOccupiedCells(g, id)
|
||||||
@@ -557,8 +556,8 @@ func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []g
|
|||||||
t,
|
t,
|
||||||
want,
|
want,
|
||||||
got,
|
got,
|
||||||
"unexpected indexed cells for object %s",
|
"unexpected indexed cells for object %d",
|
||||||
id.String(),
|
id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +576,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Point{
|
item: Point{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 150 * SCALE,
|
X: 150 * SCALE,
|
||||||
Y: 250 * SCALE,
|
Y: 250 * SCALE,
|
||||||
},
|
},
|
||||||
@@ -591,7 +590,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Point{
|
item: Point{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: -1,
|
X: -1,
|
||||||
Y: -1,
|
Y: -1,
|
||||||
},
|
},
|
||||||
@@ -605,7 +604,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Point{
|
item: Point{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 600 * SCALE,
|
X: 600 * SCALE,
|
||||||
Y: 600 * SCALE,
|
Y: 600 * SCALE,
|
||||||
},
|
},
|
||||||
@@ -619,7 +618,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Point{
|
item: Point{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 200 * SCALE,
|
X: 200 * SCALE,
|
||||||
Y: 300 * SCALE,
|
Y: 300 * SCALE,
|
||||||
},
|
},
|
||||||
@@ -656,7 +655,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
Y: 300 * SCALE,
|
Y: 300 * SCALE,
|
||||||
Radius: 50 * SCALE,
|
Radius: 50 * SCALE,
|
||||||
@@ -674,7 +673,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 50 * SCALE,
|
X: 50 * SCALE,
|
||||||
Y: 50 * SCALE,
|
Y: 50 * SCALE,
|
||||||
Radius: 75 * SCALE,
|
Radius: 75 * SCALE,
|
||||||
@@ -697,7 +696,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 575 * SCALE,
|
X: 575 * SCALE,
|
||||||
Y: 300 * SCALE,
|
Y: 300 * SCALE,
|
||||||
Radius: 50 * SCALE,
|
Radius: 50 * SCALE,
|
||||||
@@ -715,7 +714,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
Y: 575 * SCALE,
|
Y: 575 * SCALE,
|
||||||
Radius: 50 * SCALE,
|
Radius: 50 * SCALE,
|
||||||
@@ -733,7 +732,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
Y: 300 * SCALE,
|
Y: 300 * SCALE,
|
||||||
Radius: 400 * SCALE,
|
Radius: 400 * SCALE,
|
||||||
@@ -746,7 +745,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Circle{
|
item: Circle{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
Y: 300 * SCALE,
|
Y: 300 * SCALE,
|
||||||
Radius: 100 * SCALE, // bbox [200, 400) x [200, 400)
|
Radius: 100 * SCALE, // bbox [200, 400) x [200, 400)
|
||||||
@@ -787,7 +786,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 100 * SCALE,
|
X1: 100 * SCALE,
|
||||||
Y1: 200 * SCALE,
|
Y1: 200 * SCALE,
|
||||||
X2: 300 * SCALE,
|
X2: 300 * SCALE,
|
||||||
@@ -805,7 +804,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
Y1: 100 * SCALE,
|
Y1: 100 * SCALE,
|
||||||
X2: 200 * SCALE,
|
X2: 200 * SCALE,
|
||||||
@@ -823,7 +822,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 590 * SCALE,
|
X1: 590 * SCALE,
|
||||||
Y1: 200 * SCALE,
|
Y1: 200 * SCALE,
|
||||||
X2: 10 * SCALE,
|
X2: 10 * SCALE,
|
||||||
@@ -840,7 +839,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
Y1: 590 * SCALE,
|
Y1: 590 * SCALE,
|
||||||
X2: 200 * SCALE,
|
X2: 200 * SCALE,
|
||||||
@@ -857,7 +856,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 590 * SCALE,
|
X1: 590 * SCALE,
|
||||||
Y1: 590 * SCALE,
|
Y1: 590 * SCALE,
|
||||||
X2: 10 * SCALE,
|
X2: 10 * SCALE,
|
||||||
@@ -876,7 +875,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 250 * SCALE,
|
X1: 250 * SCALE,
|
||||||
Y1: 350 * SCALE,
|
Y1: 350 * SCALE,
|
||||||
X2: 250 * SCALE,
|
X2: 250 * SCALE,
|
||||||
@@ -892,7 +891,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 200 * SCALE,
|
X1: 200 * SCALE,
|
||||||
Y1: 100 * SCALE,
|
Y1: 100 * SCALE,
|
||||||
X2: 400 * SCALE,
|
X2: 400 * SCALE,
|
||||||
@@ -910,7 +909,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 100 * SCALE,
|
X1: 100 * SCALE,
|
||||||
Y1: 100 * SCALE,
|
Y1: 100 * SCALE,
|
||||||
X2: 300 * SCALE,
|
X2: 300 * SCALE,
|
||||||
@@ -931,7 +930,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
worldH: 600,
|
worldH: 600,
|
||||||
cellSize: 100 * SCALE,
|
cellSize: 100 * SCALE,
|
||||||
item: Line{
|
item: Line{
|
||||||
Id: uuid.New(),
|
Id: PrimitiveID(1),
|
||||||
X1: 600 * SCALE,
|
X1: 600 * SCALE,
|
||||||
Y1: 100 * SCALE,
|
Y1: 100 * SCALE,
|
||||||
X2: 0,
|
X2: 0,
|
||||||
@@ -960,9 +959,9 @@ func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|||||||
func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) {
|
func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) {
|
||||||
g := newTestWorld(600, 400)
|
g := newTestWorld(600, 400)
|
||||||
|
|
||||||
pID := uuid.New()
|
pID := PrimitiveID(1)
|
||||||
cID := uuid.New()
|
cID := PrimitiveID(2)
|
||||||
lID := uuid.New()
|
lID := PrimitiveID(3)
|
||||||
|
|
||||||
g.objects[pID] = Point{
|
g.objects[pID] = Point{
|
||||||
Id: pID,
|
Id: pID,
|
||||||
@@ -1007,7 +1006,7 @@ func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T)
|
|||||||
func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) {
|
func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) {
|
||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
g.objects[id] = Circle{
|
g.objects[id] = Circle{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 300 * SCALE,
|
X: 300 * SCALE,
|
||||||
@@ -1041,7 +1040,7 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) {
|
|||||||
g := newTestWorld(600, 600)
|
g := newTestWorld(600, 600)
|
||||||
g.resetGrid(100 * SCALE)
|
g.resetGrid(100 * SCALE)
|
||||||
|
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
p := Point{
|
p := Point{
|
||||||
Id: id,
|
Id: id,
|
||||||
X: 100 * SCALE,
|
X: 100 * SCALE,
|
||||||
@@ -1051,5 +1050,5 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) {
|
|||||||
g.indexObject(p)
|
g.indexObject(p)
|
||||||
|
|
||||||
got := collectOccupiedCells(g, id)
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type PointOptions struct {
|
|||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
|
||||||
|
HitSlopPx int
|
||||||
|
|
||||||
hasStyleID bool
|
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 CircleOpt func(*CircleOptions)
|
||||||
|
|
||||||
type CircleOptions struct {
|
type CircleOptions struct {
|
||||||
@@ -51,6 +57,8 @@ type CircleOptions struct {
|
|||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
|
||||||
|
HitSlopPx int
|
||||||
|
|
||||||
hasStyleID bool
|
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 LineOpt func(*LineOptions)
|
||||||
|
|
||||||
type LineOptions struct {
|
type LineOptions struct {
|
||||||
@@ -88,6 +100,8 @@ type LineOptions struct {
|
|||||||
StyleID StyleID
|
StyleID StyleID
|
||||||
Override StyleOverride
|
Override StyleOverride
|
||||||
|
|
||||||
|
HitSlopPx int
|
||||||
|
|
||||||
hasStyleID bool
|
hasStyleID bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,3 +131,7 @@ func LineWithStyleOverride(ov StyleOverride) LineOpt {
|
|||||||
o.Override = ov
|
o.Override = ov
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LineWithHitSlopPx(px int) LineOpt {
|
||||||
|
return func(o *LineOptions) { o.HitSlopPx = px }
|
||||||
|
}
|
||||||
|
|||||||
+23
-11
@@ -1,28 +1,32 @@
|
|||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
// PrimitiveID is a compact stable identifier for primitives stored in the World.
|
||||||
"github.com/google/uuid"
|
// 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.
|
// MapItem is the common interface implemented by all world primitives.
|
||||||
type MapItem interface {
|
type MapItem interface {
|
||||||
ID() uuid.UUID
|
ID() PrimitiveID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point is a point primitive in fixed-point world coordinates.
|
// Point is a point primitive in fixed-point world coordinates.
|
||||||
type Point struct {
|
type Point struct {
|
||||||
Id uuid.UUID
|
Id PrimitiveID
|
||||||
X, Y int
|
X, Y int
|
||||||
|
|
||||||
// Priority controls per-object draw ordering. Smaller draws earlier.
|
// Priority controls per-object draw ordering. Smaller draws earlier.
|
||||||
Priority int
|
Priority int
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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.
|
// Line is a line segment primitive in fixed-point world coordinates.
|
||||||
type Line struct {
|
type Line struct {
|
||||||
Id uuid.UUID
|
Id PrimitiveID
|
||||||
X1, Y1 int
|
X1, Y1 int
|
||||||
X2, Y2 int
|
X2, Y2 int
|
||||||
|
|
||||||
@@ -30,28 +34,36 @@ type Line struct {
|
|||||||
Priority int
|
Priority int
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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.
|
// Circle is a circle primitive in fixed-point world coordinates.
|
||||||
type Circle struct {
|
type Circle struct {
|
||||||
Id uuid.UUID
|
Id PrimitiveID
|
||||||
X, Y int
|
X, Y int
|
||||||
Radius int
|
Radius int
|
||||||
|
|
||||||
// Priority controls per-object draw ordering. Smaller draws earlier.
|
// Priority controls per-object draw ordering. Smaller draws earlier.
|
||||||
Priority int
|
Priority int
|
||||||
|
|
||||||
// StyleID references a resolved style in the world's style table.
|
// StyleID references a resolved style in the world's style table.
|
||||||
StyleID StyleID
|
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.
|
// 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.
|
// 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.
|
// 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.
|
// MinX returns the minimum X endpoint coordinate of the line.
|
||||||
func (l Line) MinX() int { return min(l.X1, l.X2) }
|
func (l Line) MinX() int { return min(l.X1, l.X2) }
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ package world
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPrimitiveIDs(t *testing.T) {
|
func TestPrimitiveIDs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
id1 := uuid.New()
|
id1 := PrimitiveID(1)
|
||||||
id2 := uuid.New()
|
id2 := PrimitiveID(2)
|
||||||
id3 := uuid.New()
|
id3 := PrimitiveID(3)
|
||||||
|
|
||||||
p := Point{Id: id1}
|
p := Point{Id: id1}
|
||||||
l := Line{Id: id2}
|
l := Line{Id: id2}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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).
|
// Center must be at (9,9) only, no (-1,*) or (*,-1).
|
||||||
require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
|
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,8 +2,6 @@ package world
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// drawKind is used only for stable tie-breaking when priorities are equal.
|
// drawKind is used only for stable tie-breaking when priorities are equal.
|
||||||
@@ -18,7 +16,7 @@ const (
|
|||||||
type drawItem struct {
|
type drawItem struct {
|
||||||
kind drawKind
|
kind drawKind
|
||||||
priority int
|
priority int
|
||||||
id uuid.UUID
|
id PrimitiveID
|
||||||
styleID StyleID
|
styleID StyleID
|
||||||
|
|
||||||
// Exactly one of these is set.
|
// Exactly one of these is set.
|
||||||
@@ -122,7 +120,7 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
if a.kind != b.kind {
|
if a.kind != b.kind {
|
||||||
return a.kind < b.kind
|
return a.kind < b.kind
|
||||||
}
|
}
|
||||||
return uuidLess(a.id, b.id)
|
return a.id < b.id
|
||||||
})
|
})
|
||||||
|
|
||||||
drawer.Save()
|
drawer.Save()
|
||||||
@@ -149,17 +147,3 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo
|
|||||||
drawer.Restore()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td Tile
|
|||||||
|
|
||||||
drawer.AddPoint(float64(px), float64(py), rPx)
|
drawer.AddPoint(float64(px), float64(py), rPx)
|
||||||
|
|
||||||
// For points we use Fill if fill is configured, otherwise Stroke if stroke is configured.
|
fill := alphaNonZero(lastStyle.FillColor)
|
||||||
if lastStyle.FillColor != nil {
|
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||||||
|
|
||||||
|
if fill {
|
||||||
drawer.Fill()
|
drawer.Fill()
|
||||||
} else if lastStyle.StrokeColor != nil {
|
}
|
||||||
|
if stroke {
|
||||||
|
// Stroke must be last when both are present.
|
||||||
drawer.Stroke()
|
drawer.Stroke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,9 +62,14 @@ func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td Til
|
|||||||
|
|
||||||
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
|
||||||
|
|
||||||
if lastStyle.FillColor != nil {
|
fill := alphaNonZero(lastStyle.FillColor)
|
||||||
|
stroke := alphaNonZero(lastStyle.StrokeColor)
|
||||||
|
|
||||||
|
if fill {
|
||||||
drawer.Fill()
|
drawer.Fill()
|
||||||
} else if lastStyle.StrokeColor != nil {
|
}
|
||||||
|
if stroke {
|
||||||
|
// Stroke must be last when both are present.
|
||||||
drawer.Stroke()
|
drawer.Stroke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package world
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -59,7 +57,7 @@ func (w *World) collectCandidatesForTile(r Rect) []MapItem {
|
|||||||
rowStart := w.worldToCellY(r.minY)
|
rowStart := w.worldToCellY(r.minY)
|
||||||
rowEnd := w.worldToCellY(r.maxY - 1)
|
rowEnd := w.worldToCellY(r.maxY - 1)
|
||||||
|
|
||||||
seen := make(map[uuid.UUID]struct{})
|
seen := make(map[PrimitiveID]struct{})
|
||||||
result := make([]MapItem, 0)
|
result := make([]MapItem, 0)
|
||||||
|
|
||||||
for row := rowStart; row <= rowEnd; row++ {
|
for row := rowStart; row <= rowEnd; row++ {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -664,7 +663,7 @@ func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) {
|
|||||||
if len(td.Candidates) == 0 {
|
if len(td.Candidates) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen := map[uuid.UUID]struct{}{}
|
seen := map[PrimitiveID]struct{}{}
|
||||||
for _, it := range td.Candidates {
|
for _, it := range td.Candidates {
|
||||||
_, ok := seen[it.ID()]
|
_, ok := seen[it.ID()]
|
||||||
require.False(t, ok, "candidate duplicated within a tile")
|
require.False(t, ok, "candidate duplicated within a tile")
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ const (
|
|||||||
DefaultPriorityPoint = 300
|
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.
|
// Style is a fully resolved style used by the renderer.
|
||||||
// All fields are concrete values; no "optional" markers here.
|
// All fields are concrete values; no "optional" markers here.
|
||||||
// Optionality is handled by StyleOverride during style creation.
|
// Optionality is handled by StyleOverride during style creation.
|
||||||
|
|||||||
+168
-46
@@ -3,25 +3,41 @@ package world
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errBadCoordinate = errors.New("invalid coordinates")
|
errBadCoordinate = errors.New("invalid coordinates")
|
||||||
errBadRadius = errors.New("invalid radius")
|
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,
|
// World stores torus world dimensions, all registered objects,
|
||||||
// and the grid-based spatial index built for the current viewport settings.
|
// and the grid-based spatial index built for the current viewport settings.
|
||||||
type World struct {
|
type World struct {
|
||||||
W, H int // Fixed-point world size.
|
W, H int // Fixed-point world size.
|
||||||
grid [][][]MapItem
|
grid [][][]MapItem
|
||||||
cellSize int
|
cellSize int
|
||||||
rows, cols int
|
rows, cols int
|
||||||
objects map[uuid.UUID]MapItem
|
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
|
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,11 +50,36 @@ func NewWorld(width, height int) *World {
|
|||||||
W: width * SCALE,
|
W: width * SCALE,
|
||||||
H: height * SCALE,
|
H: height * SCALE,
|
||||||
cellSize: 1,
|
cellSize: 1,
|
||||||
objects: make(map[uuid.UUID]MapItem),
|
objects: make(map[PrimitiveID]MapItem),
|
||||||
styles: NewStyleTable(),
|
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)
|
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
|
||||||
// lies inside the world bounds: [0, W) x [0, H).
|
// lies inside the world bounds: [0, W) x [0, H).
|
||||||
func (g *World) checkCoordinate(xf, yf int) bool {
|
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)
|
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.
|
// 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, opts ...PointOpt) (uuid.UUID, error) {
|
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, 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 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
o := defaultPointOptions()
|
o := defaultPointOptions()
|
||||||
@@ -81,29 +160,38 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
|
|||||||
}
|
}
|
||||||
styleID := g.resolvePointStyleID(o)
|
styleID := g.resolvePointStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
id, err := g.allocID()
|
||||||
g.objects[id] = Point{
|
if err != nil {
|
||||||
Id: id,
|
return 0, err
|
||||||
X: xf,
|
|
||||||
Y: yf,
|
|
||||||
Priority: o.Priority,
|
|
||||||
StyleID: styleID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
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, opts ...CircleOpt) (uuid.UUID, error) {
|
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, 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 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
return uuid.Nil, errBadRadius
|
return 0, errBadRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
o := defaultCircleOptions()
|
o := defaultCircleOptions()
|
||||||
@@ -114,32 +202,41 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error)
|
|||||||
}
|
}
|
||||||
styleID := g.resolveCircleStyleID(o)
|
styleID := g.resolveCircleStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
id, err := g.allocID()
|
||||||
g.objects[id] = Circle{
|
if err != nil {
|
||||||
Id: id,
|
return 0, err
|
||||||
X: xf,
|
|
||||||
Y: yf,
|
|
||||||
Radius: fixedPoint(r),
|
|
||||||
Priority: o.Priority,
|
|
||||||
StyleID: styleID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.objects[id] = Circle{
|
||||||
|
Id: id,
|
||||||
|
X: xf,
|
||||||
|
Y: yf,
|
||||||
|
Radius: fixedPoint(r),
|
||||||
|
Priority: o.Priority,
|
||||||
|
StyleID: styleID,
|
||||||
|
HitSlopPx: o.HitSlopPx,
|
||||||
|
}
|
||||||
|
|
||||||
|
g.indexDirty = true
|
||||||
|
g.rebuildIndexFromLastState()
|
||||||
|
|
||||||
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, opts ...LineOpt) (uuid.UUID, error) {
|
func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) {
|
||||||
x1f := fixedPoint(x1)
|
x1f := fixedPoint(x1)
|
||||||
y1f := fixedPoint(y1)
|
y1f := fixedPoint(y1)
|
||||||
x2f := fixedPoint(x2)
|
x2f := fixedPoint(x2)
|
||||||
y2f := fixedPoint(y2)
|
y2f := fixedPoint(y2)
|
||||||
|
|
||||||
if ok := g.checkCoordinate(x1f, y1f); !ok {
|
if ok := g.checkCoordinate(x1f, y1f); !ok {
|
||||||
return uuid.Nil, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
if ok := g.checkCoordinate(x2f, y2f); !ok {
|
if ok := g.checkCoordinate(x2f, y2f); !ok {
|
||||||
return uuid.Nil, errBadCoordinate
|
return 0, errBadCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
o := defaultLineOptions()
|
o := defaultLineOptions()
|
||||||
@@ -150,16 +247,25 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err
|
|||||||
}
|
}
|
||||||
styleID := g.resolveLineStyleID(o)
|
styleID := g.resolveLineStyleID(o)
|
||||||
|
|
||||||
id := uuid.New()
|
id, err := g.allocID()
|
||||||
g.objects[id] = Line{
|
if err != nil {
|
||||||
Id: id,
|
return 0, err
|
||||||
X1: x1f,
|
|
||||||
Y1: y1f,
|
|
||||||
X2: x2f,
|
|
||||||
Y2: y2f,
|
|
||||||
Priority: o.Priority,
|
|
||||||
StyleID: styleID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.objects[id] = Line{
|
||||||
|
Id: id,
|
||||||
|
X1: x1f,
|
||||||
|
Y1: y1f,
|
||||||
|
X2: x2f,
|
||||||
|
Y2: y2f,
|
||||||
|
Priority: o.Priority,
|
||||||
|
StyleID: styleID,
|
||||||
|
HitSlopPx: o.HitSlopPx,
|
||||||
|
}
|
||||||
|
|
||||||
|
g.indexDirty = true
|
||||||
|
g.rebuildIndexFromLastState()
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +312,10 @@ func (g *World) worldToCellY(y int) int {
|
|||||||
// resetGrid recreates the spatial grid with the given cell size
|
// resetGrid recreates the spatial grid with the given cell size
|
||||||
// and clears all previous indexing state.
|
// and clears all previous indexing state.
|
||||||
func (g *World) resetGrid(cellSize int) {
|
func (g *World) resetGrid(cellSize int) {
|
||||||
|
if cellSize <= 0 {
|
||||||
|
panic("resetGrid: invalid cell size")
|
||||||
|
}
|
||||||
|
|
||||||
g.cellSize = cellSize
|
g.cellSize = cellSize
|
||||||
g.cols = ceilDiv(g.W, g.cellSize)
|
g.cols = ceilDiv(g.W, g.cellSize)
|
||||||
g.rows = ceilDiv(g.H, 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.
|
// IndexOnViewportChange is called when UI window sizes are changed.
|
||||||
// The zoom is provided by the UI as a real multiplier and is converted
|
// cameraZoom is float64, converted inside world to fixed-point.
|
||||||
// to fixed-point inside the function.
|
|
||||||
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
||||||
cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom)
|
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
|
||||||
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp)
|
|
||||||
|
// 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
|
cellsAcrossMin := 8
|
||||||
visibleMin := min(worldWidth, worldHeight)
|
visibleMin := min(worldWidth, worldHeight)
|
||||||
|
|||||||
@@ -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
@@ -3,8 +3,6 @@ package world
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newIndexedTestWorld() *World {
|
func newIndexedTestWorld() *World {
|
||||||
@@ -13,7 +11,7 @@ func newIndexedTestWorld() *World {
|
|||||||
return w
|
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()
|
t.Helper()
|
||||||
|
|
||||||
cell := w.grid[row][col]
|
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{})
|
result := make(map[[2]int]struct{})
|
||||||
|
|
||||||
for row := range w.grid {
|
for row := range w.grid {
|
||||||
@@ -49,7 +47,7 @@ func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} {
|
|||||||
return result
|
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()
|
t.Helper()
|
||||||
|
|
||||||
got := occupiedCellsByID(w, id)
|
got := occupiedCellsByID(w, id)
|
||||||
@@ -375,7 +373,7 @@ func TestIndexObjectPoint(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
p := Point{Id: id, X: 2500, Y: 4500}
|
p := Point{Id: id, X: 2500, Y: 4500}
|
||||||
w.indexObject(p)
|
w.indexObject(p)
|
||||||
@@ -389,7 +387,7 @@ func TestIndexObjectCircleWithoutWrap(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900}
|
c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900}
|
||||||
w.indexObject(c)
|
w.indexObject(c)
|
||||||
@@ -404,7 +402,7 @@ func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
c := Circle{Id: id, X: 500, Y: 500, Radius: 900}
|
c := Circle{Id: id, X: 500, Y: 500, Radius: 900}
|
||||||
w.indexObject(c)
|
w.indexObject(c)
|
||||||
@@ -421,7 +419,7 @@ func TestIndexObjectCircleCoversWholeWorld(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000}
|
c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000}
|
||||||
w.indexObject(c)
|
w.indexObject(c)
|
||||||
@@ -440,7 +438,7 @@ func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000}
|
l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000}
|
||||||
w.indexObject(l)
|
w.indexObject(l)
|
||||||
@@ -456,7 +454,7 @@ func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000}
|
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000}
|
||||||
w.indexObject(l)
|
w.indexObject(l)
|
||||||
@@ -472,7 +470,7 @@ func TestIndexObjectLineWrapsAcrossX(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000}
|
l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000}
|
||||||
w.indexObject(l)
|
w.indexObject(l)
|
||||||
@@ -487,7 +485,7 @@ func TestIndexObjectLineWrapsAcrossY(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000}
|
l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000}
|
||||||
w.indexObject(l)
|
w.indexObject(l)
|
||||||
@@ -502,7 +500,7 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := newIndexedTestWorld()
|
w := newIndexedTestWorld()
|
||||||
id := uuid.New()
|
id := PrimitiveID(1)
|
||||||
|
|
||||||
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000}
|
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000}
|
||||||
w.indexObject(l)
|
w.indexObject(l)
|
||||||
@@ -515,10 +513,10 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type unknown struct {
|
type unknown struct {
|
||||||
id uuid.UUID
|
id PrimitiveID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u unknown) ID() uuid.UUID {
|
func (u unknown) ID() PrimitiveID {
|
||||||
return u.id
|
return u.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,5 +531,5 @@ func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
w.indexObject(unknown{id: uuid.New()})
|
w.indexObject(unknown{id: PrimitiveID(1)})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user