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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user