feat: hit on primitives
This commit is contained in:
@@ -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"
|
||||
"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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
+168
-46
@@ -3,25 +3,41 @@ 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 {
|
||||
W, H int // Fixed-point world size.
|
||||
grid [][][]MapItem
|
||||
cellSize int
|
||||
rows, cols int
|
||||
objects map[uuid.UUID]MapItem
|
||||
W, H int // Fixed-point world size.
|
||||
grid [][][]MapItem
|
||||
cellSize int
|
||||
rows, cols int
|
||||
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
|
||||
styles *StyleTable
|
||||
}
|
||||
|
||||
// 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()
|
||||
g.objects[id] = Point{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
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,32 +202,41 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error)
|
||||
}
|
||||
styleID := g.resolveCircleStyleID(o)
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Circle{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Radius: fixedPoint(r),
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
id, err := g.allocID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,16 +247,25 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err
|
||||
}
|
||||
styleID := g.resolveLineStyleID(o)
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Line{
|
||||
Id: id,
|
||||
X1: x1f,
|
||||
Y1: y1f,
|
||||
X2: x2f,
|
||||
Y2: y2f,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
id, err := g.allocID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
"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)})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user