1433 lines
38 KiB
Go
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
|
|
}
|