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