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") } }