no-wrap option; pivoted exponential zoom

This commit is contained in:
Ilia Denisov
2026-03-07 11:35:18 +02:00
committed by GitHub
parent 1de621c743
commit 477e656008
22 changed files with 605 additions and 81 deletions
+21 -12
View File
@@ -11,12 +11,13 @@ import (
type interactiveRaster struct {
widget.BaseWidget
edit *editor
min fyne.Size
raster *canvas.Raster
onLayout func(fyne.Size)
onDragged func(*fyne.DragEvent)
onDragEnd func()
edit *editor
min fyne.Size
raster *canvas.Raster
onLayout func(fyne.Size)
onScrolled func(*fyne.ScrollEvent)
onDragged func(*fyne.DragEvent)
onDragEnd func()
}
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
@@ -48,14 +49,15 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
// TappedSecondary is a right-click event
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyne.Size), onDragged func(*fyne.DragEvent), onDragEnd func()) *interactiveRaster {
func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyne.Size), onScrolled func(*fyne.ScrollEvent), onDragged func(*fyne.DragEvent), onDragEnd func()) *interactiveRaster {
r := &interactiveRaster{
// raster: canvas.NewRaster(edit.draw),
raster: raster,
edit: edit,
onLayout: onLayout,
onDragged: onDragged,
onDragEnd: onDragEnd,
raster: raster,
edit: edit,
onLayout: onLayout,
onScrolled: onScrolled,
onDragged: onDragged,
onDragEnd: onDragEnd,
}
r.ExtendBaseWidget(r)
return r
@@ -71,6 +73,13 @@ func bgPattern(x, y, _, _ int) color.Color {
return color.Gray{Y: 84}
}
func (r *interactiveRaster) Scrolled(e *fyne.ScrollEvent) {
if r.onScrolled == nil {
return
}
r.onScrolled(e)
}
func (r *interactiveRaster) Dragged(e *fyne.DragEvent) {
if r.onDragged == nil {
return
+82 -2
View File
@@ -2,6 +2,7 @@ package client
import (
"image"
"math"
"sync"
"fyne.io/fyne/v2"
@@ -78,11 +79,89 @@ func (e *editor) updateSizes() {
e.wp.CameraZoom = e.world.CorrectCameraZoom(e.wp.CameraZoom, e.wp.ViewportWidthPx, e.wp.ViewportHeightPx)
e.world.IndexOnViewportChange(e.wp.ViewportWidthPx, e.wp.ViewportHeightPx, e.wp.CameraZoom)
e.world.ClampRenderParamsNoWrap(e.wp)
e.co.Request(*e.wp)
}
}
func (e *editor) onScrolled(s *fyne.ScrollEvent) {
vw := e.wp.ViewportWidthPx
vh := e.wp.ViewportHeightPx
if vw <= 0 || vh <= 0 {
return
}
// Cursor position in viewport pixels (Fyne units -> px by multiplying).
cxPx := int(float32(s.Position.X) * e.canvasScale)
cyPx := int(float32(s.Position.Y) * e.canvasScale)
if cxPx < 0 {
cxPx = 0
} else if cxPx > vw {
cxPx = vw
}
if cyPx < 0 {
cyPx = 0
} else if cyPx > vh {
cyPx = vh
}
oldZoom := e.wp.CameraZoom
// Exponential zoom:
// - Each "notch" multiplies zoom by a fixed factor.
// - Using DY directly as exponent gives smooth trackpad behavior too.
//
// Tune base:
// base=1.10 => ~10% per wheel step (if DY≈1 per step)
// base=1.05 => ~5% per step
//
// In user settings, better store on percents userZoomLevelPercent = (0,100]: base = 1 + (userZoomLevelPercent) / 10
const base = 1.005
// Negative DY => zoom out, positive DY => zoom in (depending on platform settings).
// If you want inverted direction, negate float64(s.Scrolled.DY).
delta := float64(s.Scrolled.DY)
newZoom := oldZoom * math.Pow(base, delta)
// Clamp/correct (min/max + prevent wrap if needed).
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
if newZoom == oldZoom {
return
}
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
if err != nil {
return
}
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
if err != nil {
return
}
newCamX, newCamY := world.PivotZoomCameraNoWrap(
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
vw, vh,
cxPx, cyPx,
oldZoomFp, newZoomFp,
)
e.wp.CameraZoom = newZoom
e.wp.CameraXWorldFp = newCamX
e.wp.CameraYWorldFp = newCamY
e.world.IndexOnViewportChange(vw, vh, e.wp.CameraZoom)
// No-wrap clamp to avoid "detaching" from borders.
e.world.ClampRenderParamsNoWrap(e.wp)
// Zoom changes are best done as full redraw.
e.world.ForceFullRedrawNext()
e.co.Request(*e.wp)
}
func (e *editor) onDragged(ev *fyne.DragEvent) {
e.pan.Dragged(ev)
}
@@ -120,6 +199,7 @@ func NewEditor() *editor {
CameraXWorldFp: 300 * world.SCALE,
CameraYWorldFp: 300 * world.SCALE,
// Viewport sizes and margins will be filled from draw(w,h).
Options: &world.RenderOptions{DisableWrapScroll: false},
},
canvasScale: 1.0,
}
@@ -132,7 +212,7 @@ func NewEditor() *editor {
return e.draw(wPx, hPx)
})
e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onDragged, e.onDradEnd)
e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd)
e.pan = NewPanController(e)
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
+4
View File
@@ -54,6 +54,10 @@ func (e *editor) GetParams() world.RenderParams {
func (e *editor) UpdateParams(fn func(p *world.RenderParams)) {
e.mu.Lock()
fn(e.wp)
// IMPORTANT: clamp camera if no-wrap
e.world.ClampRenderParamsNoWrap(e.wp)
p := e.wp // snapshot
e.mu.Unlock()
+117
View File
@@ -0,0 +1,117 @@
package world
// 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
}
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
}
+92
View File
@@ -0,0 +1,92 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld(t *testing.T) {
t.Parallel()
worldW := 100 * SCALE
worldH := 100 * SCALE
zoomFp := SCALE // 1.0x
// viewport 40px => span 40 units => half 20.
viewportW, viewportH := 40, 40
// Too far left/up => clamp to minCam=20
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 20*SCALE, cx)
require.Equal(t, 20*SCALE, cy)
// Too far right/down => clamp to maxCam=world-half=80
cx, cy = ClampCameraNoWrapViewport(
99*SCALE, 99*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 80*SCALE, cx)
require.Equal(t, 80*SCALE, cy)
// Inside range => unchanged
cx, cy = ClampCameraNoWrapViewport(
50*SCALE, 60*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
func TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter(t *testing.T) {
t.Parallel()
worldW := 50 * SCALE
worldH := 50 * SCALE
zoomFp := SCALE
// viewport 60px => span 60 units > world 50
viewportW, viewportH := 60, 60
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, worldW/2, cx)
require.Equal(t, worldH/2, cy)
}
func TestWorldClampRenderParamsNoWrap_UsesViewportClamp(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
p := RenderParams{
ViewportWidthPx: 40,
ViewportHeightPx: 40,
MarginXPx: 10,
MarginYPx: 10,
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.ClampRenderParamsNoWrap(&p)
// viewport half is 20, not 30 (margins ignored)
require.Equal(t, 20*SCALE, p.CameraXWorldFp)
require.Equal(t, 20*SCALE, p.CameraYWorldFp)
}
+44
View File
@@ -0,0 +1,44 @@
package world
// 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
}
+50
View File
@@ -0,0 +1,50 @@
package world
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPivotZoom_CursorAtCenter_KeepsCamera(t *testing.T) {
t.Parallel()
cx, cy := PivotZoomCameraNoWrap(
50*SCALE, 60*SCALE,
100, 80,
50, 40, // cursor at center
SCALE, 2*SCALE,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
func TestPivotZoom_RightEdge_ZoomInMovesCameraRight(t *testing.T) {
t.Parallel()
// viewport 100px, cursor at x=100 (right edge), center is 50 => offX=50px
// zoom 1->2 halves worldPerPx, so camera must move towards the cursor to keep the same world point.
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
100, 50,
SCALE, 2*SCALE,
)
require.Greater(t, cx, cx0)
}
func TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft(t *testing.T) {
t.Parallel()
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
0, 50,
SCALE, 2*SCALE,
)
require.Less(t, cx, cx0)
}
+37 -10
View File
@@ -21,6 +21,10 @@ type RenderOptions struct {
Style *RenderStyle
// Incremental controls incremental pan behavior. If nil, defaults are used.
Incremental *IncrementalPolicy
// DisableWrapScroll controls whether the world is treated as a torus (false)
// or as a bounded plane without wrap (true).
// Default is false.
DisableWrapScroll bool
}
var (
@@ -66,7 +70,7 @@ func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.Mar
// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
func (p RenderParams) CameraZoomFp() (int, error) {
return cameraZoomToWorldFixed(p.CameraZoom)
return CameraZoomToWorldFixed(p.CameraZoom)
}
// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
@@ -210,6 +214,8 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
layers = params.Options.Layers
}
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
// --- Try incremental path first when state is initialized and geometry matches ---
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
if derr == nil {
@@ -246,13 +252,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx)
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H)
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H)
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
@@ -319,13 +325,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx)
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H)
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H)
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
@@ -352,13 +358,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
switch layer {
case RenderLayerPoints:
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx, allowWrap)
case RenderLayerCircles:
applyCircleStyle(drawer, style)
drawCirclesFromPlan(drawer, plan, w.W, w.H)
drawCirclesFromPlan(drawer, plan, w.W, w.H, allowWrap)
case RenderLayerLines:
applyLineStyle(drawer, style)
drawLinesFromPlan(drawer, plan, w.W, w.H)
drawLinesFromPlan(drawer, plan, w.W, w.H, allowWrap)
default:
panic("render: unknown layer")
}
@@ -448,6 +454,27 @@ func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
return out
}
// tileWorldRectNoWrap returns 0..1 tiles for a bounded world (no wrap).
// It intersects the expanded unwrapped rect with the canonical world [0..W)x[0..H).
func tileWorldRectNoWrap(worldRect Rect, W, H int) []WorldTile {
ix0 := max(worldRect.minX, 0)
iy0 := max(worldRect.minY, 0)
ix1 := min(worldRect.maxX, W)
iy1 := min(worldRect.maxY, H)
if ix0 >= ix1 || iy0 >= iy1 {
return nil
}
return []WorldTile{
{
Rect: Rect{minX: ix0, maxX: ix1, minY: iy0, maxY: iy1},
OffsetX: 0,
OffsetY: 0,
},
}
}
func isEmptyRectPx(r RectPx) bool {
return r.W <= 0 || r.H <= 0
}
+17 -10
View File
@@ -1,18 +1,20 @@
package world
// renderCirclesStageA performs a full expanded-canvas redraw but renders ONLY Circle primitives.
func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
// func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error {
// plan, err := w.buildRenderPlanStageA(params)
// if err != nil {
// return err
// }
drawCirclesFromPlan(drawer, plan, w.W, w.H)
return nil
}
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
// drawCirclesFromPlan(drawer, plan, w.W, w.H, allowWrap)
// return nil
// }
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) {
func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
@@ -40,7 +42,12 @@ func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH
copiesToDraw := make([]circleCopy, 0, len(circles))
for _, c := range circles {
shifts := circleWrapShifts(c, worldW, worldH)
var shifts []wrapShift
if allowWrap {
shifts = circleWrapShifts(c, worldW, worldH)
} else {
shifts = []wrapShift{{dx: 0, dy: 0}}
}
for _, s := range shifts {
if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) {
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
+39 -3
View File
@@ -36,7 +36,7 @@ func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
drawCirclesFromPlan(d, plan, w.W, w.H, true)
// Expect 4 circle copies, one per tile that covers the expanded canvas.
wantNames := []string{
@@ -118,7 +118,7 @@ func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
drawCirclesFromPlan(d, plan, w.W, w.H, true)
// No circles => no commands.
require.Empty(t, d.Commands())
@@ -149,7 +149,7 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w.W, w.H)
drawCirclesFromPlan(d, plan, w.W, w.H, true)
// There should be at least one AddCircle.
cmds := d.CommandsByName("AddCircle")
@@ -161,3 +161,39 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
require.Equal(t, 4.0, c.Args[2])
}
}
func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
_, err := w.AddCircle(9, 9, 2)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
DisableWrapScroll: true,
Layers: []RenderLayer{RenderLayerCircles},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
cmds := d.CommandsByName("AddCircle")
require.Len(t, cmds, 1)
// Center must be at (9,9) only, no (-1,*) or (*,-1).
require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
}
+1 -1
View File
@@ -74,7 +74,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawCirclesFromPlan(d, plan, w2.W, w2.H)
drawCirclesFromPlan(d, plan, w2.W, w2.H, true)
cmds := d.CommandsByName("AddCircle")
require.Len(t, cmds, len(tt.wantCenters))
+17 -10
View File
@@ -2,15 +2,17 @@ package world
// renderLinesStageB performs a full expanded-canvas redraw but renders ONLY Line primitives.
// It uses the Stage A render plan: tiles + per-tile clip + per-tile candidates.
func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
// func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error {
// plan, err := w.buildRenderPlanStageA(params)
// if err != nil {
// return err
// }
drawLinesFromPlan(drawer, plan, w.W, w.H)
return nil
}
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
// drawLinesFromPlan(drawer, plan, w.W, w.H, allowWrap)
// return nil
// }
// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn.
// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting.
@@ -20,7 +22,7 @@ type lineSeg struct {
}
// drawLinesFromPlan executes a lines-only draw from an already built render plan.
func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) {
func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool) {
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
@@ -42,7 +44,12 @@ func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH i
// Collect segments that actually intersect this tile's canonical rect.
segsToDraw := make([]lineSeg, 0, len(lines))
for _, l := range lines {
segs := torusShortestLineSegments(l, worldW, worldH)
var segs []lineSeg
if allowWrap {
segs = torusShortestLineSegments(l, worldW, worldH)
} else {
segs = []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
}
for _, s := range segs {
if segmentIntersectsRect(s, td.Tile.Rect) {
segsToDraw = append(segsToDraw, s)
+39 -2
View File
@@ -31,7 +31,7 @@ func TestDrawLinesFromPlan_WrapX_SplitsAndDrawsInThreeXTiles(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawLinesFromPlan(d, plan, w.W, w.H)
drawLinesFromPlan(d, plan, w.W, w.H, true)
// Expect drawing in 3 X tiles (left partial, middle full, right partial) for the central Y tile:
// Each tile group: Save, ClipRect, AddLine(s), Stroke, Restore
@@ -95,7 +95,7 @@ func TestDrawLinesFromPlan_WrapY_SplitsAndDrawsInThreeYTiles(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawLinesFromPlan(d, plan, w.W, w.H)
drawLinesFromPlan(d, plan, w.W, w.H, true)
// Here we expect 3 Y tiles for the central X tile:
// Top partial, middle full (two segments), bottom partial.
@@ -156,3 +156,40 @@ func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T)
require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0])
require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1])
}
func TestLines_NoWrap_TieCaseDoesNotWrap(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE)
// Tie-case along X: 1 -> 6 in world of 10.
_, err := w.AddLine(1, 5, 6, 5)
require.NoError(t, err)
for _, obj := range w.objects {
w.indexObject(obj)
}
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
DisableWrapScroll: true,
Layers: []RenderLayer{RenderLayerLines},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
lines := d.CommandsByName("AddLine")
require.Len(t, lines, 1)
// At zoom=1 and margin=0, world==canvas, so pixels equal world units.
require.Equal(t, []float64{1, 5, 6, 5}, lines[0].Args)
}
+7 -1
View File
@@ -59,7 +59,13 @@ func (w *World) buildRenderPlanStageA(params RenderParams) (RenderPlan, error) {
return RenderPlan{}, err
}
tiles := tileWorldRect(worldRect, w.W, w.H)
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
var tiles []WorldTile
if allowWrap {
tiles = tileWorldRect(worldRect, w.W, w.H)
} else {
tiles = tileWorldRectNoWrap(worldRect, w.W, w.H)
}
// Query candidates per tile.
batches, err := w.collectCandidatesForTiles(tiles)
+24 -17
View File
@@ -3,33 +3,35 @@ package world
import "math"
// renderPointsStageA performs a full expanded-canvas redraw but renders ONLY Point primitives.
func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error {
plan, err := w.buildRenderPlanStageA(params)
if err != nil {
return err
}
// func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error {
// plan, err := w.buildRenderPlanStageA(params)
// if err != nil {
// return err
// }
style := DefaultRenderStyle()
if params.Options != nil && params.Options.Style != nil {
style = *params.Options.Style
}
// style := DefaultRenderStyle()
// if params.Options != nil && params.Options.Style != nil {
// style = *params.Options.Style
// }
applyPointStyle(drawer, style)
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
return nil
}
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
// applyPointStyle(drawer, style)
// drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx, allowWrap)
// return nil
// }
// drawPointsFromPlan keeps backward compatibility for older tests/helpers.
func drawPointsFromPlan(drawer PrimitiveDrawer, plan RenderPlan) {
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)
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) {
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 {
@@ -65,7 +67,12 @@ func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, world
copiesToDraw := make([]pointCopy, 0, len(points))
for _, p := range points {
shifts := pointWrapShifts(p, rWorldFp, worldW, worldH)
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})
+2 -2
View File
@@ -39,7 +39,7 @@ func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
drawPointsFromPlan(d, plan, true)
// We expect 4 point copies:
// (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1)
@@ -124,7 +124,7 @@ func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
drawPointsFromPlan(d, plan, true)
// No points => no drawing commands at all.
require.Empty(t, d.Commands())
+1 -1
View File
@@ -82,7 +82,7 @@ func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testin
style.PointRadiusPx = 2.0
applyPointStyle(d, style)
drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx)
drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, true)
cmds := d.CommandsByName("AddPoint")
require.Len(t, cmds, len(tt.wantCenters))
+1
View File
@@ -55,6 +55,7 @@ func (s *RenderScheduler) runOnUIThread() {
params := s.latest
s.mu.Unlock()
s.w.ClampRenderParamsNoWrap(&params)
_ = s.w.Render(s.drawer, params) // handle error in real code
s.mu.Lock()
@@ -44,9 +44,9 @@ func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) {
d := &fakePrimitiveDrawer{}
// Execute all three passes over the same plan.
drawPointsFromPlan(d, plan)
drawCirclesFromPlan(d, plan, w.W, w.H)
drawLinesFromPlan(d, plan, w.W, w.H)
drawPointsFromPlan(d, plan, true)
drawCirclesFromPlan(d, plan, w.W, w.H, true)
drawLinesFromPlan(d, plan, w.W, w.H, true)
names := d.CommandNames()
+2 -2
View File
@@ -35,8 +35,8 @@ func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
require.NoError(t, err)
d := &fakePrimitiveDrawer{}
drawPointsFromPlan(d, plan)
drawCirclesFromPlan(d, plan, w.W, w.H)
drawPointsFromPlan(d, plan, true)
drawCirclesFromPlan(d, plan, w.W, w.H, true)
names := d.CommandNames()
require.Contains(t, names, "AddPoint")
+3 -3
View File
@@ -217,7 +217,7 @@ func shortestWrappedDelta(from, to, size int) (a int, b int) {
return
}
// cameraZoomToWorldFixed converts a UI-facing zoom multiplier into the package
// 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
@@ -225,7 +225,7 @@ func shortestWrappedDelta(from, to, size int) (a int, b int) {
//
// 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) {
func CameraZoomToWorldFixed(cameraZoom float64) (int, error) {
if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) {
return 0, errInvalidCameraZoom
}
@@ -242,7 +242,7 @@ func cameraZoomToWorldFixed(cameraZoom float64) (int, error) {
// 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)
zoomFp, err := CameraZoomToWorldFixed(cameraZoom)
if err != nil {
panic(err)
}
+2 -2
View File
@@ -302,7 +302,7 @@ func TestCameraZoomToWorldFixed(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
@@ -347,7 +347,7 @@ func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
require.ErrorIs(t, err, errInvalidCameraZoom)
require.Zero(t, got)
})