From 477e656008ef35efac8022b06eb4f4ae08bc5225 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 7 Mar 2026 11:35:18 +0200 Subject: [PATCH] no-wrap option; pivoted exponential zoom --- client/canvas.go | 33 +++-- client/editor.go | 84 ++++++++++++- client/ui.go | 4 + client/world/camera_clamp.go | 117 ++++++++++++++++++ client/world/camera_clamp_test.go | 92 ++++++++++++++ client/world/pivot_zoom.go | 44 +++++++ client/world/pivot_zoom_test.go | 50 ++++++++ client/world/renderer.go | 47 +++++-- client/world/renderer_circles.go | 27 ++-- client/world/renderer_circles_test.go | 42 ++++++- client/world/renderer_circles_wrap_test.go | 2 +- client/world/renderer_lines.go | 27 ++-- client/world/renderer_lines_test.go | 41 +++++- client/world/renderer_plan.go | 8 +- client/world/renderer_points.go | 41 +++--- client/world/renderer_points_test.go | 4 +- client/world/renderer_points_wrap_test.go | 2 +- client/world/renderer_schedule.go | 1 + .../renderer_smoke_all_primitives_test.go | 6 +- client/world/renderer_smoke_mixed_test.go | 4 +- client/world/util.go | 6 +- client/world/util_test.go | 4 +- 22 files changed, 605 insertions(+), 81 deletions(-) create mode 100644 client/world/camera_clamp.go create mode 100644 client/world/camera_clamp_test.go create mode 100644 client/world/pivot_zoom.go create mode 100644 client/world/pivot_zoom_test.go diff --git a/client/canvas.go b/client/canvas.go index a5f9baf..1d8554c 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -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 diff --git a/client/editor.go b/client/editor.go index 2bccb7a..252b2cd 100644 --- a/client/editor.go +++ b/client/editor.go @@ -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. diff --git a/client/ui.go b/client/ui.go index 4aa6e2b..274a63d 100644 --- a/client/ui.go +++ b/client/ui.go @@ -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() diff --git a/client/world/camera_clamp.go b/client/world/camera_clamp.go new file mode 100644 index 0000000..b329887 --- /dev/null +++ b/client/world/camera_clamp.go @@ -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 +} diff --git a/client/world/camera_clamp_test.go b/client/world/camera_clamp_test.go new file mode 100644 index 0000000..8942f7f --- /dev/null +++ b/client/world/camera_clamp_test.go @@ -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) +} diff --git a/client/world/pivot_zoom.go b/client/world/pivot_zoom.go new file mode 100644 index 0000000..7fdb755 --- /dev/null +++ b/client/world/pivot_zoom.go @@ -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 +} diff --git a/client/world/pivot_zoom_test.go b/client/world/pivot_zoom_test.go new file mode 100644 index 0000000..6711116 --- /dev/null +++ b/client/world/pivot_zoom_test.go @@ -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) +} diff --git a/client/world/renderer.go b/client/world/renderer.go index 80d83e5..f1839cb 100644 --- a/client/world/renderer.go +++ b/client/world/renderer.go @@ -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 } diff --git a/client/world/renderer_circles.go b/client/world/renderer_circles.go index e295fbf..df463bc 100644 --- a/client/world/renderer_circles.go +++ b/client/world/renderer_circles.go @@ -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}) diff --git a/client/world/renderer_circles_test.go b/client/world/renderer_circles_test.go index 0faec3b..cee4d93 100644 --- a/client/world/renderer_circles_test.go +++ b/client/world/renderer_circles_test.go @@ -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) +} diff --git a/client/world/renderer_circles_wrap_test.go b/client/world/renderer_circles_wrap_test.go index a262d63..2cb997e 100644 --- a/client/world/renderer_circles_wrap_test.go +++ b/client/world/renderer_circles_wrap_test.go @@ -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)) diff --git a/client/world/renderer_lines.go b/client/world/renderer_lines.go index d098dba..d14dda5 100644 --- a/client/world/renderer_lines.go +++ b/client/world/renderer_lines.go @@ -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) diff --git a/client/world/renderer_lines_test.go b/client/world/renderer_lines_test.go index d643967..2f20cae 100644 --- a/client/world/renderer_lines_test.go +++ b/client/world/renderer_lines_test.go @@ -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) +} diff --git a/client/world/renderer_plan.go b/client/world/renderer_plan.go index c69d40a..de3541d 100644 --- a/client/world/renderer_plan.go +++ b/client/world/renderer_plan.go @@ -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) diff --git a/client/world/renderer_points.go b/client/world/renderer_points.go index 655eb79..52c1de8 100644 --- a/client/world/renderer_points.go +++ b/client/world/renderer_points.go @@ -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}) diff --git a/client/world/renderer_points_test.go b/client/world/renderer_points_test.go index ee14f23..b4bdd7d 100644 --- a/client/world/renderer_points_test.go +++ b/client/world/renderer_points_test.go @@ -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()) diff --git a/client/world/renderer_points_wrap_test.go b/client/world/renderer_points_wrap_test.go index ce27b92..e4dc084 100644 --- a/client/world/renderer_points_wrap_test.go +++ b/client/world/renderer_points_wrap_test.go @@ -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)) diff --git a/client/world/renderer_schedule.go b/client/world/renderer_schedule.go index 2adeb31..cf32ecb 100644 --- a/client/world/renderer_schedule.go +++ b/client/world/renderer_schedule.go @@ -55,6 +55,7 @@ func (s *RenderScheduler) runOnUIThread() { params := s.latest s.mu.Unlock() + s.w.ClampRenderParamsNoWrap(¶ms) _ = s.w.Render(s.drawer, params) // handle error in real code s.mu.Lock() diff --git a/client/world/renderer_smoke_all_primitives_test.go b/client/world/renderer_smoke_all_primitives_test.go index 5bd9ec1..8d6c6d6 100644 --- a/client/world/renderer_smoke_all_primitives_test.go +++ b/client/world/renderer_smoke_all_primitives_test.go @@ -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() diff --git a/client/world/renderer_smoke_mixed_test.go b/client/world/renderer_smoke_mixed_test.go index 272e057..bfe7473 100644 --- a/client/world/renderer_smoke_mixed_test.go +++ b/client/world/renderer_smoke_mixed_test.go @@ -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") diff --git a/client/world/util.go b/client/world/util.go index e80b63d..1906297 100644 --- a/client/world/util.go +++ b/client/world/util.go @@ -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) } diff --git a/client/world/util_test.go b/client/world/util_test.go index 9e85bd1..da27a53 100644 --- a/client/world/util_test.go +++ b/client/world/util_test.go @@ -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) })