248 lines
6.2 KiB
Go
248 lines
6.2 KiB
Go
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}
|
|
}
|