Files
galaxy-game/client/world/util.go
T
Ilia Denisov 5029857fe4 world refactor
2026-03-17 12:48:05 +03:00

1433 lines
38 KiB
Go

package world
import (
"errors"
"fmt"
"hash"
"hash/fnv"
"image/color"
"math"
"math/bits"
)
var (
errInvalidCameraZoom = errors.New("invalid camera zoom")
)
const (
// SCALE is the fixed-point multiplier used across the package.
// A real value of 1.0 is represented as SCALE.
SCALE = 1000
// MIN_ZOOM and MAX_ZOOM define the supported zoom range in fixed-point form.
// They are reserved for future validation/clamping logic.
MIN_ZOOM = int(SCALE / 4) // 0.25x
MAX_ZOOM = int(SCALE * 32) // 32x
// cellSizeMin and cellSizeMax bound the automatically selected grid cell size.
cellSizeMin = 16 * SCALE
cellSizeMax = 512 * SCALE
)
// Rect is a half-open rectangle in fixed-point world coordinates:
// [minX, maxX) x [minY, maxY).
type Rect struct {
minX, maxX int
minY, maxY int
}
// wrap maps value into the half-open interval [0, size).
// It supports negative input values and is used for torus coordinates.
func wrap(value, size int) int {
r := value % size
if r < 0 {
r += size
}
return r
}
// clamp limits value to the closed interval [minValue, maxValue].
func clamp(value, minValue, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
// ceilDiv returns ceil(a / b) for positive integers.
func ceilDiv(a, b int) int {
return (a + b - 1) / b
}
// floorDiv returns floor(a / b) for b > 0 and supports negative a.
func floorDiv(a, b int) int {
if b <= 0 {
panic("floorDiv: non-positive divisor")
}
q := a / b
r := a % b
if r != 0 && a < 0 {
q--
}
return q
}
// fixedPoint converts a real value into the package fixed-point representation
// using nearest-integer rounding.
func fixedPoint(v float64) int {
return int(math.Round(v * SCALE))
}
// abs returns the absolute value of v.
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
// viewportPxToWorldFixed converts a viewport size in pixels into the visible
// world size in fixed-point coordinates for the given fixed-point zoom.
func viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp int) (int, int) {
return PixelSpanToWorldFixed(viewportWidthPx, cameraZoomFp),
PixelSpanToWorldFixed(viewportHeightPx, cameraZoomFp)
}
// worldToCell maps a world coordinate to a grid cell index.
// The input coordinate is wrapped on the torus before the cell is computed.
// The function panics when the grid configuration is invalid.
func worldToCell(value, worldSize, cells, cellSize int) int {
if cells <= 0 || cellSize <= 0 {
panic(fmt.Sprintf("worldToCell: cells=%d cellSize=%d", cells, cellSize))
}
wrappedValue := wrap(value, worldSize)
cell := wrappedValue / cellSize
if cell >= cells {
cell = cells - 1
}
return cell
}
// splitByWrap splits a half-open rectangle by torus wrap into 1..4 rectangles
// fully contained inside [0, W) x [0, H).
func splitByWrap(W, H, minX, maxX, minY, maxY int) []Rect {
width := maxX - minX
height := maxY - minY
if width <= 0 || height <= 0 {
return nil
}
if width >= W {
minX = 0
maxX = W
}
if height >= H {
minY = 0
maxY = H
}
type xPart struct {
minX, maxX int
}
xParts := make([]xPart, 0, 2)
if minX >= 0 && maxX <= W {
xParts = append(xParts, xPart{minX: minX, maxX: maxX})
} else {
wrappedMinX := wrap(minX, W)
wrappedMaxX := wrap(maxX, W)
if wrappedMinX < wrappedMaxX {
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: wrappedMaxX})
} else {
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: W})
if wrappedMaxX > 0 {
xParts = append(xParts, xPart{minX: 0, maxX: wrappedMaxX})
}
}
}
result := make([]Rect, 0, 4)
for _, xp := range xParts {
if minY >= 0 && maxY <= H {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: minY, maxY: maxY,
})
continue
}
wrappedMinY := wrap(minY, H)
wrappedMaxY := wrap(maxY, H)
if wrappedMinY < wrappedMaxY {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: wrappedMinY, maxY: wrappedMaxY,
})
} else {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: wrappedMinY, maxY: H,
})
if wrappedMaxY > 0 {
result = append(result, Rect{
minX: xp.minX, maxX: xp.maxX,
minY: 0, maxY: wrappedMaxY,
})
}
}
}
return result
}
// PixelSpanToWorldFixed converts a span in pixels into a span in fixed-point
// world coordinates for the given fixed-point zoom.
func PixelSpanToWorldFixed(spanPx int, zoomFp int) int {
return (spanPx * SCALE * SCALE) / zoomFp
}
// shortestWrappedDelta returns a canonical torus representation of the pair
// (from, to) along a single axis of length size.
//
// The resulting delta (b - a) is normalized into the half-open interval
// [-size/2, size/2). This makes the tie-case deterministic:
// when the points are exactly half a world apart, wrap is always applied in
// the direction that produces the negative delta.
func shortestWrappedDelta(from, to, size int) (a int, b int) {
a, b = from, to
delta := to - from
half := size / 2
if delta >= half {
a += size
return
}
if delta < -half {
b += size
return
}
return
}
// CameraZoomToWorldFixed converts a UI-facing zoom multiplier into the package
// fixed-point representation used by world-space calculations.
//
// The input zoom is expected to be a finite positive real value where 1.0 means
// the neutral zoom level. The result is rounded to the nearest fixed-point value.
//
// An error is returned when the input is invalid or when rounding would produce
// a non-positive fixed-point zoom.
func CameraZoomToWorldFixed(cameraZoom float64) (int, error) {
if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) {
return 0, errInvalidCameraZoom
}
zoomFp := int(math.Round(cameraZoom * SCALE))
if zoomFp <= 0 {
return 0, errInvalidCameraZoom
}
return zoomFp, nil
}
// mustCameraZoomToWorldFixed is the panic-on-error variant of
// cameraZoomToWorldFixed. It is intended for internal code paths where invalid
// zoom is considered a programmer or integration error and must fail fast.
func mustCameraZoomToWorldFixed(cameraZoom float64) int {
zoomFp, err := CameraZoomToWorldFixed(cameraZoom)
if err != nil {
panic(err)
}
return zoomFp
}
// ClampCameraNoWrapViewport clamps camera center so that the VIEWPORT world-rect
// stays within the bounded world [0..worldW) x [0..worldH), when possible.
//
// This is the correct clamp for user panning when wrap is disabled.
// Margins (expanded canvas) are intentionally ignored here; they may extend outside the world.
func ClampCameraNoWrapViewport(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
zoomFp int,
worldW, worldH int,
) (int, int) {
if zoomFp <= 0 {
panic("ClampCameraNoWrapViewport: invalid zoom")
}
if viewportW < 0 || viewportH < 0 {
panic("ClampCameraNoWrapViewport: negative viewport")
}
if worldW <= 0 || worldH <= 0 {
panic("ClampCameraNoWrapViewport: invalid world size")
}
spanW := PixelSpanToWorldFixed(viewportW, zoomFp)
spanH := PixelSpanToWorldFixed(viewportH, zoomFp)
halfW := spanW / 2
halfH := spanH / 2
cameraXWorldFp = clampCameraAxis(cameraXWorldFp, worldW, halfW)
cameraYWorldFp = clampCameraAxis(cameraYWorldFp, worldH, halfH)
return cameraXWorldFp, cameraYWorldFp
}
// ClampCameraNoWrapExpanded clamps camera center so that the EXPANDED CANVAS world-rect
// (viewport + margins) stays within the bounded world, when possible.
//
// This is stricter than viewport-based clamp and can prevent panning when margins are large.
func ClampCameraNoWrapExpanded(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
marginX, marginY int,
zoomFp int,
worldW, worldH int,
) (int, int) {
if zoomFp <= 0 {
panic("ClampCameraNoWrapExpanded: invalid zoom")
}
if viewportW < 0 || viewportH < 0 || marginX < 0 || marginY < 0 {
panic("ClampCameraNoWrapExpanded: negative sizes")
}
if worldW <= 0 || worldH <= 0 {
panic("ClampCameraNoWrapExpanded: invalid world size")
}
canvasW := viewportW + 2*marginX
canvasH := viewportH + 2*marginY
spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
spanH := PixelSpanToWorldFixed(canvasH, zoomFp)
halfW := spanW / 2
halfH := spanH / 2
cameraXWorldFp = clampCameraAxis(cameraXWorldFp, worldW, halfW)
cameraYWorldFp = clampCameraAxis(cameraYWorldFp, worldH, halfH)
return cameraXWorldFp, cameraYWorldFp
}
// clampCameraAxis clamps one camera axis for bounded-world rendering.
//
// If the visible span is larger than the world on that axis, the camera is
// forced to the world center to keep the result deterministic.
func clampCameraAxis(cam, worldSize, halfSpan int) int {
// If viewport/span does not fit: force center.
if 2*halfSpan > worldSize {
return worldSize / 2
}
minCam := halfSpan
maxCam := worldSize - halfSpan
if cam < minCam {
return minCam
}
if cam > maxCam {
return maxCam
}
return cam
}
// ClampRenderParamsNoWrap clamps camera center in-place when wrap is disabled.
// It uses viewport-based clamp (NOT expanded) so panning remains possible even with margins.
func (w *World) ClampRenderParamsNoWrap(p *RenderParams) {
if p == nil {
return
}
allowWrap := true
if p.Options != nil && p.Options.DisableWrapScroll {
allowWrap = false
}
if allowWrap {
return
}
zoomFp, err := p.CameraZoomFp()
if err != nil || zoomFp <= 0 {
return
}
cx, cy := ClampCameraNoWrapViewport(
p.CameraXWorldFp, p.CameraYWorldFp,
p.ViewportWidthPx, p.ViewportHeightPx,
zoomFp,
w.W, w.H,
)
p.CameraXWorldFp = cx
p.CameraYWorldFp = cy
}
// PivotZoomCameraNoWrap adjusts camera center so that the world point under the cursor remains fixed
// when zoom changes from oldZoomFp to newZoomFp.
//
// Coordinate conventions:
// - CameraXWorldFp/YWorldFp is the center of the viewport in world-fixed units.
// - cursorXPx/cursorYPx are pixel coordinates relative to the top-left of the viewport.
// - viewportW/H are viewport size in pixels.
//
// This function does not clamp the result; caller should clamp for no-wrap mode using ClampCameraNoWrapViewport.
func PivotZoomCameraNoWrap(
cameraXWorldFp, cameraYWorldFp int,
viewportW, viewportH int,
cursorXPx, cursorYPx int,
oldZoomFp, newZoomFp int,
) (newCamX, newCamY int) {
if oldZoomFp <= 0 || newZoomFp <= 0 {
panic("PivotZoomCameraNoWrap: invalid zoom")
}
if viewportW <= 0 || viewportH <= 0 {
panic("PivotZoomCameraNoWrap: invalid viewport")
}
// Offset of cursor from viewport center in pixels.
offXPx := cursorXPx - viewportW/2
offYPx := cursorYPx - viewportH/2
// World-fixed per 1 pixel at each zoom.
// (Conservative: integer arithmetic, consistent with PixelSpanToWorldFixed.)
oldWorldPerPx := PixelSpanToWorldFixed(1, oldZoomFp)
newWorldPerPx := PixelSpanToWorldFixed(1, newZoomFp)
// World point under cursor before zoom:
// world = camera + offsetPx * worldPerPx
worldX := cameraXWorldFp + offXPx*oldWorldPerPx
worldY := cameraYWorldFp + offYPx*oldWorldPerPx
// Choose new camera so that the same world point stays under cursor:
// camera' = world - offsetPx * newWorldPerPx
newCamX = worldX - offXPx*newWorldPerPx
newCamY = worldY - offYPx*newWorldPerPx
return
}
// worldFixedToCameraZoom converts a fixed-point zoom value back into the
// UI-facing floating-point representation where 1.0 means neutral zoom.
func worldFixedToCameraZoom(zoomFp int) float64 {
return float64(zoomFp) / float64(SCALE)
}
// requiredZoomToFitWorld returns the minimum fixed-point zoom needed so that
// a viewport span of viewportSpanPx pixels does not exceed a world span of
// worldSpanFp fixed-point units.
//
// The result is rounded up, not down, because the fit constraint must be
// satisfied conservatively: after correction, the visible world span must
// never be larger than the actual world span.
func requiredZoomToFitWorld(viewportSpanPx, worldSpanFp int) int {
if viewportSpanPx < 0 {
panic("requiredZoomToFitWorld: negative viewport span")
}
if worldSpanFp <= 0 {
panic("requiredZoomToFitWorld: non-positive world span")
}
if viewportSpanPx == 0 {
return 0
}
return ceilDiv(viewportSpanPx*SCALE*SCALE, worldSpanFp)
}
// correctCameraZoomFp corrects a fixed-point zoom value using two groups
// of constraints:
//
// 1. Fit-to-world constraints derived from viewport and world sizes.
// These have the highest priority and prevent the viewport from becoming
// larger than the world on any axis, which would otherwise expose wrap
// on the visible user area.
//
// 2. Optional UI zoom bounds [minZoomFp, maxZoomFp].
// A zero bound means "ignore this bound".
// If fit-to-world requires a zoom larger than maxZoomFp, the fit constraint
// wins and maxZoomFp is ignored for that case.
//
// The function returns either the corrected zoom or currentZoomFp unchanged
// when no correction is required.
func correctCameraZoomFp(
currentZoomFp int,
viewportWidthPx, viewportHeightPx int,
worldWidthFp, worldHeightFp int,
minZoomFp, maxZoomFp int,
) int {
if currentZoomFp <= 0 {
panic("correctCameraZoomFp: non-positive current zoom")
}
if viewportWidthPx < 0 || viewportHeightPx < 0 {
panic("correctCameraZoomFp: negative viewport size")
}
if worldWidthFp <= 0 || worldHeightFp <= 0 {
panic("correctCameraZoomFp: non-positive world size")
}
if minZoomFp < 0 || maxZoomFp < 0 {
panic("correctCameraZoomFp: negative zoom bound")
}
if minZoomFp > 0 && maxZoomFp > 0 && minZoomFp > maxZoomFp {
panic("correctCameraZoomFp: min zoom greater than max zoom")
}
// Start from the user zoom.
result := currentZoomFp
// Apply min bound first (only increases zoom, always valid).
if minZoomFp > 0 && result < minZoomFp {
result = minZoomFp
}
// Apply max bound tentatively. This can be overridden later by the anti-wrap constraint.
if maxZoomFp > 0 && result > maxZoomFp {
result = maxZoomFp
}
// If viewport is larger than the world on any axis at the current result zoom,
// increase zoom to the minimum value that prevents wrap in the visible area.
requiredFitX := requiredZoomToFitWorld(viewportWidthPx, worldWidthFp)
requiredFitY := requiredZoomToFitWorld(viewportHeightPx, worldHeightFp)
requiredFit := max(requiredFitX, requiredFitY)
if requiredFit > 0 && result < requiredFit {
result = requiredFit
}
// Re-apply max bound only if it does not conflict with the anti-wrap requirement.
// If anti-wrap requires zoom > maxZoomFp, anti-wrap wins.
if maxZoomFp > 0 && result > maxZoomFp && requiredFit <= maxZoomFp {
result = maxZoomFp
}
return result
}
// CorrectCameraZoom adapts fixed-point zoom correction for UI code.
//
// currentZoom is the user-facing zoom multiplier in floating-point form.
// The result is returned in the same representation.
func (w *World) CorrectCameraZoom(
currentZoom float64,
viewportWidthPx int,
viewportHeightPx int,
) float64 {
currentZoomFp := mustCameraZoomToWorldFixed(currentZoom)
correctedZoomFp := correctCameraZoomFp(
currentZoomFp,
viewportWidthPx,
viewportHeightPx,
w.W,
w.H,
MIN_ZOOM,
MAX_ZOOM,
)
return worldFixedToCameraZoom(correctedZoomFp)
}
// u128 is an unsigned 128-bit integer for safe squared comparisons.
type u128 struct{ hi, lo uint64 }
// u128FromMul64 returns the full 128-bit product of two uint64 values.
func u128FromMul64(a, b uint64) u128 {
hi, lo := bits.Mul64(a, b)
return u128{hi: hi, lo: lo}
}
// u128Add returns the 128-bit sum a+b.
func u128Add(a, b u128) u128 {
lo := a.lo + b.lo
hi := a.hi + b.hi
if lo < a.lo {
hi++
}
return u128{hi: hi, lo: lo}
}
// u128Cmp compares two unsigned 128-bit values.
func u128Cmp(a, b u128) int {
if a.hi < b.hi {
return -1
}
if a.hi > b.hi {
return 1
}
if a.lo < b.lo {
return -1
}
if a.lo > b.lo {
return 1
}
return 0
}
// abs64 returns the absolute value of x.
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}
// sqU128Int64 returns x*x as an unsigned 128-bit value.
func sqU128Int64(x int64) u128 {
u := uint64(abs64(x))
return u128FromMul64(u, u)
}
// distSqU128 returns dx*dx + dy*dy as an unsigned 128-bit value.
func distSqU128(dx, dy int64) u128 {
return u128Add(sqU128Int64(dx), sqU128Int64(dy))
}
// shortestTorusDelta returns the shortest signed delta from a->b on a torus axis of size.
// It is deterministic in tie cases (size even, exactly half): chooses negative direction.
func shortestTorusDelta(a, b, size int) int64 {
d := int64(b - a)
s := int64(size)
half := s / 2
// Normalize d into (-s, s).
d = d % s
if d <= -half {
d += s
} else if d > half {
d -= s
}
// Tie case when size even and d == +half: choose -half.
if s%2 == 0 && d == half {
d = -half
}
return d
}
// effectiveHitSlopPx resolves the per-primitive hit slop, falling back to def
// when the primitive does not override it explicitly.
func effectiveHitSlopPx(hitSlopPx int, def int) int {
if hitSlopPx > 0 {
return hitSlopPx
}
return def
}
// alphaNonZero reports whether c is non-nil and has a non-zero alpha channel.
func alphaNonZero(c color.Color) bool {
if c == nil {
return false
}
_, _, _, a := c.RGBA()
return a != 0
}
// hitPoint performs point hit testing in world-fixed coordinates.
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
}
// hitCircle performs circle hit testing in world-fixed coordinates, including
// fill-vs-stroke semantics and minimum point-like radius handling.
func hitCircle(c Circle, effRadiusFp int, 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(effRadiusFp, 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 effRadiusFp > effR {
effR = effRadiusFp
}
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: effRadiusFp,
}, true
}
return Hit{}, false
}
// Filled circle: hit-test by disc (surface).
if fillVisible {
r := effRadiusFp + 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: effRadiusFp,
}, 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 := effRadiusFp
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: effRadiusFp,
}, true
}
return Hit{}, false
}
// hitLine performs line hit testing against the torus-shortest segment set used
// by the renderer.
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}
}
// torusShortestLineSegmentsInto converts a Line primitive into 1..4 canonical segments
// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline.
//
// It appends results into dst using tmp as an intermediate buffer.
// No allocations occur if dst/tmp have sufficient capacity (>=4).
func torusShortestLineSegmentsInto(dst, tmp []lineSeg, l Line, worldW, worldH int) ([]lineSeg, []lineSeg) {
dst = dst[:0]
tmp = tmp[:0]
// Step 1: choose the torus-shortest representation in unwrapped space.
ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW)
ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH)
// Step 2: shift so that A is inside canonical [0..W) x [0..H).
shiftX := floorDiv(ax, worldW) * worldW
shiftY := floorDiv(ay, worldH) * worldH
ax -= shiftX
bx -= shiftX
ay -= shiftY
by -= shiftY
dst = append(dst, lineSeg{x1: ax, y1: ay, x2: bx, y2: by})
// Step 3: split by X boundary if needed (jump-aware).
tmp = splitSegmentsByXInto(tmp, dst, worldW)
// Step 4: split by Y boundary if needed (jump-aware).
dst = splitSegmentsByYInto(dst, tmp, worldH)
return dst, tmp
}
// torusShortestLineSegments is a compatibility wrapper that allocates.
// Prefer torusShortestLineSegmentsInto in hot paths.
func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg {
dst := make([]lineSeg, 0, 4)
tmp := make([]lineSeg, 0, 4)
dst, _ = torusShortestLineSegmentsInto(dst, tmp, l, worldW, worldH)
return dst
}
// splitSegmentsByXInto appends 1..2 segments for each input segment into out, without allocating.
// out is reset to length 0 by this function.
func splitSegmentsByXInto(out []lineSeg, segs []lineSeg, worldW int) []lineSeg {
out = out[:0]
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, x1 is expected inside [0..worldW). Only x2 may be outside.
if x2 >= 0 && x2 < worldW {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dx == 0 {
out = append(out, s)
continue
}
if x2 >= worldW {
// Crosses the right boundary at x=worldW, then reappears at x=0.
bx := worldW
num := bx - x1
iy := y1 + (dy*num)/dx
s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy}
s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2}
out = append(out, s1, s2)
continue
}
// x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW.
bx := 0
num := bx - x1
iy := y1 + (dy*num)/dx
s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy}
s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2}
out = append(out, s1, s2)
}
return out
}
// splitSegmentsByYInto appends 1..2 segments for each input segment into out, without allocating.
// out is reset to length 0 by this function.
func splitSegmentsByYInto(out []lineSeg, segs []lineSeg, worldH int) []lineSeg {
out = out[:0]
for _, s := range segs {
x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2
// After normalization, y1 is expected inside [0..worldH). Only y2 may be outside.
if y2 >= 0 && y2 < worldH {
out = append(out, s)
continue
}
dx := x2 - x1
dy := y2 - y1
if dy == 0 {
out = append(out, s)
continue
}
if y2 >= worldH {
// Crosses the top boundary at y=worldH, then reappears at y=0.
by := worldH
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH}
s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH}
out = append(out, s1, s2)
continue
}
// y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH.
by := 0
num := by - y1
ix := x1 + (dx*num)/dy
s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0}
s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH}
out = append(out, s1, s2)
}
return out
}
// mergeOverrides applies userOv on top of classOv, preserving the "nil means
// not specified" semantics used by StyleOverride fields.
func mergeOverrides(classOv, userOv StyleOverride) StyleOverride {
out := classOv
// Colors: nil means "unset"
if userOv.FillColor != nil {
out.FillColor = userOv.FillColor
}
if userOv.StrokeColor != nil {
out.StrokeColor = userOv.StrokeColor
}
// Pointers: nil means "unset"
if userOv.StrokeWidthPx != nil {
out.StrokeWidthPx = userOv.StrokeWidthPx
}
if userOv.StrokeDashes != nil {
out.StrokeDashes = userOv.StrokeDashes
}
if userOv.StrokeDashOffset != nil {
out.StrokeDashOffset = userOv.StrokeDashOffset
}
if userOv.PointRadiusPx != nil {
out.PointRadiusPx = userOv.PointRadiusPx
}
return out
}
// hashU64 writes v to the hash in little-endian form.
// We keep it manual to avoid extra allocations and dependencies.
func hashU64(h hash.Hash64, v uint64) {
var b [8]byte
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
b[4] = byte(v >> 32)
b[5] = byte(v >> 40)
b[6] = byte(v >> 48)
b[7] = byte(v >> 56)
_, _ = h.Write(b[:])
}
// hashBool writes a boolean value to the fingerprint stream.
func hashBool(h hash.Hash64, v bool) {
if v {
hashU64(h, 1)
} else {
hashU64(h, 0)
}
}
// hashColor writes a color value to the fingerprint stream.
func hashColor(h hash.Hash64, c color.Color) {
if c == nil {
hashU64(h, 0)
return
}
r, g, b, a := c.RGBA()
hashU64(h, uint64(r))
hashU64(h, uint64(g))
hashU64(h, uint64(b))
hashU64(h, uint64(a))
}
// fingerprint returns a stable hash of the override content.
//
// Notes on semantics:
// - FillColor / StrokeColor: nil means "unset" (do not override). Transparent override is represented
// by a non-nil color with alpha=0.
// - Pointer fields (*float64, *[]float64) encode presence via nil/non-nil.
// - StrokeDashes: nil pointer means "unset"; non-nil pointer to nil slice means "set to nil".
func (o StyleOverride) fingerprint() uint64 {
h := fnv.New64a() // returns hash.Hash64
// FillColor / StrokeColor
hashBool(h, o.FillColor != nil)
hashColor(h, o.FillColor)
hashBool(h, o.StrokeColor != nil)
hashColor(h, o.StrokeColor)
// StrokeWidthPx
hashBool(h, o.StrokeWidthPx != nil)
if o.StrokeWidthPx != nil {
hashU64(h, math.Float64bits(*o.StrokeWidthPx))
}
// StrokeDashes
hashBool(h, o.StrokeDashes != nil)
if o.StrokeDashes != nil {
ds := *o.StrokeDashes
if ds == nil {
// Explicitly set to nil slice
hashU64(h, 0xffffffffffffffff)
} else {
hashU64(h, uint64(len(ds)))
for _, v := range ds {
hashU64(h, math.Float64bits(v))
}
}
}
// StrokeDashOffset
hashBool(h, o.StrokeDashOffset != nil)
if o.StrokeDashOffset != nil {
hashU64(h, math.Float64bits(*o.StrokeDashOffset))
}
// PointRadiusPx
hashBool(h, o.PointRadiusPx != nil)
if o.PointRadiusPx != nil {
hashU64(h, math.Float64bits(*o.PointRadiusPx))
}
return h.Sum64()
}
// drawPointsFromPlan keeps backward compatibility for older tests/helpers.
func drawPointsFromPlan(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool) {
// Default world sizes are unknown here, so this wrapper is no longer suitable for wrap-aware points.
// Keep it for historical call sites only if they pass through Render().
// Prefer calling drawPointsFromPlanWithRadius with world sizes.
drawPointsFromPlanWithRadius(drawer, plan, 0, 0, DefaultRenderStyle().PointRadiusPx, allowWrap)
}
// drawPointsFromPlanWithRadius executes a points-only draw from an already built render plan,
// using the provided screen-space radius. If worldW/worldH are zero, wrap copies are disabled.
func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, radiusPx float64, allowWrap bool) {
// Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed.
rPxInt := int(math.Ceil(radiusPx))
if rPxInt < 0 {
rPxInt = 0
}
rWorldFp := 0
if rPxInt > 0 {
rWorldFp = PixelSpanToWorldFixed(rPxInt, plan.ZoomFp)
}
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
points := make([]Point, 0, len(td.Candidates))
for _, it := range td.Candidates {
p, ok := it.(Point)
if !ok {
continue
}
points = append(points, p)
}
if len(points) == 0 {
continue
}
type pointCopy struct {
p Point
dx int
dy int
}
copiesToDraw := make([]pointCopy, 0, len(points))
for _, p := range points {
var shifts []wrapShift
if allowWrap {
shifts = pointWrapShifts(p, rWorldFp, worldW, worldH)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, pc := range copiesToDraw {
p := pc.p
px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+pc.dx)-plan.WorldRect.minX, plan.ZoomFp)
py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+pc.dy)-plan.WorldRect.minY, plan.ZoomFp)
drawer.AddPoint(float64(px), float64(py), radiusPx)
}
drawer.Fill()
drawer.Restore()
}
}
// pointWrapShifts returns the torus-copy offsets required for a point marker
// whose visible disc may cross world edges.
func pointWrapShifts(p Point, rWorldFp, worldW, worldH int) []wrapShift {
// If world sizes are unknown, do not generate wrap copies.
if worldW <= 0 || worldH <= 0 {
return []wrapShift{{dx: 0, dy: 0}}
}
xShifts := []int{0}
yShifts := []int{0}
if p.X+rWorldFp >= worldW {
xShifts = append(xShifts, -worldW)
}
if p.X-rWorldFp < 0 {
xShifts = append(xShifts, worldW)
}
if p.Y+rWorldFp >= worldH {
yShifts = append(yShifts, -worldH)
}
if p.Y-rWorldFp < 0 {
yShifts = append(yShifts, worldH)
}
out := make([]wrapShift, 0, len(xShifts)*len(yShifts))
for _, dx := range xShifts {
for _, dy := range yShifts {
out = append(out, wrapShift{dx: dx, dy: dy})
}
}
return out
}
// pointCopyIntersectsTile reports whether a particular wrapped point copy can
// contribute pixels inside the given world tile.
func pointCopyIntersectsTile(p Point, rWorldFp, dx, dy int, tile WorldTile) bool {
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
px := p.X + tile.OffsetX + dx
py := p.Y + tile.OffsetY + dy
minX := px - rWorldFp
maxX := px + rWorldFp
minY := py - rWorldFp
maxY := py + rWorldFp
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool, circleRadiusScaleFp int) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
// Filter only circles; skip tiles that have no circles.
circles := make([]Circle, 0, len(td.Candidates))
for _, it := range td.Candidates {
c, ok := it.(Circle)
if !ok {
continue
}
circles = append(circles, c)
}
if len(circles) == 0 {
continue
}
// Determine which circle copies actually intersect this tile segment.
type circleCopy struct {
c Circle
dx int
dy int
}
copiesToDraw := make([]circleCopy, 0, len(circles))
for _, c := range circles {
var shifts []wrapShift
effRadius := circleRadiusEffFp(c.Radius, circleRadiusScaleFp)
if allowWrap {
shifts = circleWrapShifts(c.X, c.Y, effRadius, worldW, worldH)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, worldW, worldH) {
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
}
}
}
if len(copiesToDraw) == 0 {
continue
}
drawer.Save()
drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
for _, cc := range copiesToDraw {
c := cc.c
// Project the circle center for this tile copy (tile offset + wrap shift).
cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+cc.dx)-plan.WorldRect.minX, plan.ZoomFp)
cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+cc.dy)-plan.WorldRect.minY, plan.ZoomFp)
// Radius is a world span.
rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp)
drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
}
drawer.Fill()
drawer.Restore()
}
}
// wrapShift stores one torus-copy offset in world-fixed coordinates.
type wrapShift struct {
dx int
dy int
}
// circleWrapShiftsInto appends required torus-copy shifts for a circle into dst and returns the resulting slice.
// It never allocates if dst has enough capacity.
//
// The 0-shift is always included. Additional copies are included when the circle's bbox crosses world edges.
func circleWrapShiftsInto(dst []wrapShift, cx, cy, radiusFp, worldW, worldH int) []wrapShift {
dst = dst[:0]
// Always include the original.
dst = append(dst, wrapShift{dx: 0, dy: 0})
if radiusFp <= 0 {
return dst
}
minX := cx - radiusFp
maxX := cx + radiusFp
minY := cy - radiusFp
maxY := cy + radiusFp
needLeft := minX < 0
needRight := maxX > worldW
needTop := minY < 0
needBottom := maxY > worldH
// X-only copies.
if needLeft {
dst = append(dst, wrapShift{dx: +worldW, dy: 0})
}
if needRight {
dst = append(dst, wrapShift{dx: -worldW, dy: 0})
}
// Y-only copies.
if needTop {
dst = append(dst, wrapShift{dx: 0, dy: +worldH})
}
if needBottom {
dst = append(dst, wrapShift{dx: 0, dy: -worldH})
}
// Corner copies (combine X and Y).
if (needLeft || needRight) && (needTop || needBottom) {
var dxs [2]int
dxn := 0
if needLeft {
dxs[dxn] = +worldW
dxn++
}
if needRight {
dxs[dxn] = -worldW
dxn++
}
var dys [2]int
dyn := 0
if needTop {
dys[dyn] = +worldH
dyn++
}
if needBottom {
dys[dyn] = -worldH
dyn++
}
for i := 0; i < dxn; i++ {
for j := 0; j < dyn; j++ {
dst = append(dst, wrapShift{dx: dxs[i], dy: dys[j]})
}
}
}
return dst
}
// circleWrapShifts is a compatibility wrapper that allocates.
// Prefer circleWrapShiftsInto in hot paths.
func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift {
var dst []wrapShift
return circleWrapShiftsInto(dst, cx, cy, radiusFp, worldW, worldH)
}
// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment.
// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis.
func circleCopyIntersectsTile(cx, cy, radiusFp, dx, dy int, tile WorldTile, worldW, worldH int) bool {
// Unwrapped tile segment bounds.
segMinX := tile.OffsetX + tile.Rect.minX
segMaxX := tile.OffsetX + tile.Rect.maxX
segMinY := tile.OffsetY + tile.Rect.minY
segMaxY := tile.OffsetY + tile.Rect.maxY
// Circle bbox in the same unwrapped space (apply shift + tile offset).
cx = cx + tile.OffsetX + dx
cy = cy + tile.OffsetY + dy
minX := cx - radiusFp
maxX := cx + radiusFp
minY := cy - radiusFp
maxY := cy + radiusFp
// Treat bbox as half-open for intersection checks.
if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY {
return false
}
return true
}