337 lines
9.2 KiB
Go
337 lines
9.2 KiB
Go
package world
|
|
|
|
import (
|
|
"github.com/stretchr/testify/require"
|
|
"image/color"
|
|
"testing"
|
|
)
|
|
|
|
// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
|
|
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)
|
|
}
|
|
|
|
// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits.
|
|
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)
|
|
}
|
|
|
|
// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits.
|
|
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)
|
|
}
|
|
|
|
// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center.
|
|
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)
|
|
}
|
|
|
|
// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area.
|
|
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults
|
|
w.IndexOnViewportChange(100, 100, 1.0)
|
|
|
|
// raw radius=2 units, centered at (5,5)
|
|
_, err := w.AddCircle(5, 5, 2)
|
|
require.NoError(t, err)
|
|
|
|
// scale=2 => eff radius=4
|
|
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
|
|
w.Reindex()
|
|
|
|
params := RenderParams{
|
|
ViewportWidthPx: 100,
|
|
ViewportHeightPx: 100,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 5 * SCALE,
|
|
CameraYWorldFp: 5 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
// Tap at +4 px from center should hit (eff radius 4).
|
|
buf := make([]Hit, 0, 8)
|
|
hits, err := w.HitTest(buf, ¶ms, 50+4, 50)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, hits)
|
|
require.Equal(t, KindCircle, hits[0].Kind)
|
|
|
|
// Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options.
|
|
// We'll add a small-slope circle and test deterministically.
|
|
}
|
|
|
|
// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table.
|
|
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type tc struct {
|
|
name string
|
|
fillVisible bool
|
|
rawRadius int // world units (not fixed); zoom=1 => 1px per unit
|
|
scaleFp int
|
|
hitSlopPx int
|
|
cursorDxPx int // offset from center in pixels along X axis
|
|
wantHit bool
|
|
wantKind PrimitiveKind
|
|
}
|
|
|
|
// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
|
|
params := RenderParams{
|
|
ViewportWidthPx: 200,
|
|
ViewportHeightPx: 200,
|
|
MarginXPx: 0,
|
|
MarginYPx: 0,
|
|
CameraXWorldFp: 10 * SCALE,
|
|
CameraYWorldFp: 10 * SCALE,
|
|
CameraZoom: 1.0,
|
|
}
|
|
|
|
tests := []tc{
|
|
{
|
|
name: "filled: on boundary hits (R=4, S=1, dx=4)",
|
|
fillVisible: true,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE, // eff radius = 4
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 4,
|
|
wantHit: true,
|
|
wantKind: KindCircle,
|
|
},
|
|
{
|
|
name: "filled: outside beyond slop misses (R=4, S=1, dx=6)",
|
|
fillVisible: true,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE,
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 6, // 6 > R+S = 5
|
|
wantHit: false,
|
|
},
|
|
{
|
|
name: "filled: just inside slop hits (R=4, S=1, dx=5)",
|
|
fillVisible: true,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE,
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 5, // == R+S
|
|
wantHit: true,
|
|
wantKind: KindCircle,
|
|
},
|
|
{
|
|
name: "stroke-only: center must miss even if slop would cover",
|
|
fillVisible: false,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE, // eff radius = 4
|
|
hitSlopPx: 10, // huge, would normally include center without our rule
|
|
cursorDxPx: 0,
|
|
wantHit: false,
|
|
},
|
|
{
|
|
name: "stroke-only: on ring hits (R=4, S=1, dx=4)",
|
|
fillVisible: false,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE,
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 4,
|
|
wantHit: true,
|
|
wantKind: KindCircle,
|
|
},
|
|
{
|
|
name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
|
|
fillVisible: false,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE,
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 2, // 2 < R-S = 3
|
|
wantHit: false,
|
|
},
|
|
{
|
|
name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
|
|
fillVisible: false,
|
|
rawRadius: 2,
|
|
scaleFp: 2 * SCALE,
|
|
hitSlopPx: 1,
|
|
cursorDxPx: 6, // 6 > R+S = 5
|
|
wantHit: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(20, 20)
|
|
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
|
|
|
require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))
|
|
|
|
// Build a stroke-only circle style if needed.
|
|
var opts []CircleOpt
|
|
opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))
|
|
|
|
if !tt.fillVisible {
|
|
// Force fill alpha=0 => stroke-only for hit-test and rendering.
|
|
sw := 1.0
|
|
styleID := w.AddStyleCircle(StyleOverride{
|
|
FillColor: color.RGBA{A: 0},
|
|
StrokeColor: color.RGBA{A: 255},
|
|
StrokeWidthPx: &sw,
|
|
})
|
|
opts = append(opts, CircleWithStyleID(styleID))
|
|
}
|
|
|
|
_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
|
|
require.NoError(t, err)
|
|
|
|
w.Reindex()
|
|
|
|
// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
|
|
cx := params.ViewportWidthPx/2 + tt.cursorDxPx
|
|
cy := params.ViewportHeightPx / 2
|
|
|
|
buf := make([]Hit, 0, 8)
|
|
hits, err := w.HitTest(buf, ¶ms, cx, cy)
|
|
require.NoError(t, err)
|
|
|
|
if !tt.wantHit {
|
|
require.Empty(t, hits)
|
|
return
|
|
}
|
|
|
|
require.NotEmpty(t, hits)
|
|
require.Equal(t, tt.wantKind, hits[0].Kind)
|
|
})
|
|
}
|
|
}
|