226 lines
5.6 KiB
Go
226 lines
5.6 KiB
Go
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]struct{}, 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()] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use caller buffer as backing store; keep only best cap(out) hits.
|
|
out = out[:0]
|
|
limit := cap(out)
|
|
|
|
for id := range cand {
|
|
cur, ok := w.objects[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
h, ok := w.hitOne(cur, 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, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), 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")
|
|
}
|
|
}
|