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 }