no-wrap option; pivoted exponential zoom
This commit is contained in:
+10
-1
@@ -15,6 +15,7 @@ type interactiveRaster struct {
|
|||||||
min fyne.Size
|
min fyne.Size
|
||||||
raster *canvas.Raster
|
raster *canvas.Raster
|
||||||
onLayout func(fyne.Size)
|
onLayout func(fyne.Size)
|
||||||
|
onScrolled func(*fyne.ScrollEvent)
|
||||||
onDragged func(*fyne.DragEvent)
|
onDragged func(*fyne.DragEvent)
|
||||||
onDragEnd func()
|
onDragEnd func()
|
||||||
}
|
}
|
||||||
@@ -48,12 +49,13 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
|||||||
// TappedSecondary is a right-click event
|
// TappedSecondary is a right-click event
|
||||||
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
|
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{
|
r := &interactiveRaster{
|
||||||
// raster: canvas.NewRaster(edit.draw),
|
// raster: canvas.NewRaster(edit.draw),
|
||||||
raster: raster,
|
raster: raster,
|
||||||
edit: edit,
|
edit: edit,
|
||||||
onLayout: onLayout,
|
onLayout: onLayout,
|
||||||
|
onScrolled: onScrolled,
|
||||||
onDragged: onDragged,
|
onDragged: onDragged,
|
||||||
onDragEnd: onDragEnd,
|
onDragEnd: onDragEnd,
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,13 @@ func bgPattern(x, y, _, _ int) color.Color {
|
|||||||
return color.Gray{Y: 84}
|
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) {
|
func (r *interactiveRaster) Dragged(e *fyne.DragEvent) {
|
||||||
if r.onDragged == nil {
|
if r.onDragged == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
+82
-2
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"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.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.IndexOnViewportChange(e.wp.ViewportWidthPx, e.wp.ViewportHeightPx, e.wp.CameraZoom)
|
||||||
|
e.world.ClampRenderParamsNoWrap(e.wp)
|
||||||
e.co.Request(*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) {
|
func (e *editor) onDragged(ev *fyne.DragEvent) {
|
||||||
e.pan.Dragged(ev)
|
e.pan.Dragged(ev)
|
||||||
}
|
}
|
||||||
@@ -120,6 +199,7 @@ func NewEditor() *editor {
|
|||||||
CameraXWorldFp: 300 * world.SCALE,
|
CameraXWorldFp: 300 * world.SCALE,
|
||||||
CameraYWorldFp: 300 * world.SCALE,
|
CameraYWorldFp: 300 * world.SCALE,
|
||||||
// Viewport sizes and margins will be filled from draw(w,h).
|
// Viewport sizes and margins will be filled from draw(w,h).
|
||||||
|
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||||
},
|
},
|
||||||
canvasScale: 1.0,
|
canvasScale: 1.0,
|
||||||
}
|
}
|
||||||
@@ -132,7 +212,7 @@ func NewEditor() *editor {
|
|||||||
return e.draw(wPx, hPx)
|
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)
|
e.pan = NewPanController(e)
|
||||||
|
|
||||||
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
|
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ func (e *editor) GetParams() world.RenderParams {
|
|||||||
func (e *editor) UpdateParams(fn func(p *world.RenderParams)) {
|
func (e *editor) UpdateParams(fn func(p *world.RenderParams)) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
fn(e.wp)
|
fn(e.wp)
|
||||||
|
|
||||||
|
// IMPORTANT: clamp camera if no-wrap
|
||||||
|
e.world.ClampRenderParamsNoWrap(e.wp)
|
||||||
|
|
||||||
p := e.wp // snapshot
|
p := e.wp // snapshot
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -21,6 +21,10 @@ type RenderOptions struct {
|
|||||||
Style *RenderStyle
|
Style *RenderStyle
|
||||||
// Incremental controls incremental pan behavior. If nil, defaults are used.
|
// Incremental controls incremental pan behavior. If nil, defaults are used.
|
||||||
Incremental *IncrementalPolicy
|
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 (
|
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.
|
// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
|
||||||
func (p RenderParams) CameraZoomFp() (int, error) {
|
func (p RenderParams) CameraZoomFp() (int, error) {
|
||||||
return cameraZoomToWorldFixed(p.CameraZoom)
|
return CameraZoomToWorldFixed(p.CameraZoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
|
// 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
|
layers = params.Options.Layers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
|
|
||||||
// --- Try incremental path first when state is initialized and geometry matches ---
|
// --- Try incremental path first when state is initialized and geometry matches ---
|
||||||
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
dxPx, dyPx, derr := w.ComputePanShiftPx(params)
|
||||||
if derr == nil {
|
if derr == nil {
|
||||||
@@ -246,13 +252,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
switch layer {
|
switch layer {
|
||||||
case RenderLayerPoints:
|
case RenderLayerPoints:
|
||||||
applyPointStyle(drawer, style)
|
applyPointStyle(drawer, style)
|
||||||
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx)
|
drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
|
||||||
case RenderLayerCircles:
|
case RenderLayerCircles:
|
||||||
applyCircleStyle(drawer, style)
|
applyCircleStyle(drawer, style)
|
||||||
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H)
|
drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
|
||||||
case RenderLayerLines:
|
case RenderLayerLines:
|
||||||
applyLineStyle(drawer, style)
|
applyLineStyle(drawer, style)
|
||||||
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H)
|
drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H, allowWrap)
|
||||||
default:
|
default:
|
||||||
panic("render: unknown layer")
|
panic("render: unknown layer")
|
||||||
}
|
}
|
||||||
@@ -319,13 +325,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
switch layer {
|
switch layer {
|
||||||
case RenderLayerPoints:
|
case RenderLayerPoints:
|
||||||
applyPointStyle(drawer, style)
|
applyPointStyle(drawer, style)
|
||||||
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx)
|
drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx, allowWrap)
|
||||||
case RenderLayerCircles:
|
case RenderLayerCircles:
|
||||||
applyCircleStyle(drawer, style)
|
applyCircleStyle(drawer, style)
|
||||||
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H)
|
drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
|
||||||
case RenderLayerLines:
|
case RenderLayerLines:
|
||||||
applyLineStyle(drawer, style)
|
applyLineStyle(drawer, style)
|
||||||
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H)
|
drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H, allowWrap)
|
||||||
default:
|
default:
|
||||||
panic("render: unknown layer")
|
panic("render: unknown layer")
|
||||||
}
|
}
|
||||||
@@ -352,13 +358,13 @@ func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
|
|||||||
switch layer {
|
switch layer {
|
||||||
case RenderLayerPoints:
|
case RenderLayerPoints:
|
||||||
applyPointStyle(drawer, style)
|
applyPointStyle(drawer, style)
|
||||||
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
|
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx, allowWrap)
|
||||||
case RenderLayerCircles:
|
case RenderLayerCircles:
|
||||||
applyCircleStyle(drawer, style)
|
applyCircleStyle(drawer, style)
|
||||||
drawCirclesFromPlan(drawer, plan, w.W, w.H)
|
drawCirclesFromPlan(drawer, plan, w.W, w.H, allowWrap)
|
||||||
case RenderLayerLines:
|
case RenderLayerLines:
|
||||||
applyLineStyle(drawer, style)
|
applyLineStyle(drawer, style)
|
||||||
drawLinesFromPlan(drawer, plan, w.W, w.H)
|
drawLinesFromPlan(drawer, plan, w.W, w.H, allowWrap)
|
||||||
default:
|
default:
|
||||||
panic("render: unknown layer")
|
panic("render: unknown layer")
|
||||||
}
|
}
|
||||||
@@ -448,6 +454,27 @@ func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
|
|||||||
return out
|
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 {
|
func isEmptyRectPx(r RectPx) bool {
|
||||||
return r.W <= 0 || r.H <= 0
|
return r.W <= 0 || r.H <= 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
package world
|
package world
|
||||||
|
|
||||||
// renderCirclesStageA performs a full expanded-canvas redraw but renders ONLY Circle primitives.
|
// renderCirclesStageA performs a full expanded-canvas redraw but renders ONLY Circle primitives.
|
||||||
func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error {
|
// func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error {
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
// plan, err := w.buildRenderPlanStageA(params)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return err
|
// return err
|
||||||
}
|
// }
|
||||||
|
|
||||||
drawCirclesFromPlan(drawer, plan, w.W, w.H)
|
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
return nil
|
|
||||||
}
|
// drawCirclesFromPlan(drawer, plan, w.W, w.H, allowWrap)
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
// drawCirclesFromPlan executes a circles-only draw from an already built render plan.
|
// 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 {
|
for _, td := range plan.Tiles {
|
||||||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -40,7 +42,12 @@ func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH
|
|||||||
copiesToDraw := make([]circleCopy, 0, len(circles))
|
copiesToDraw := make([]circleCopy, 0, len(circles))
|
||||||
|
|
||||||
for _, c := range 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 {
|
for _, s := range shifts {
|
||||||
if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) {
|
if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) {
|
||||||
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
|
copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy})
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
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.
|
// Expect 4 circle copies, one per tile that covers the expanded canvas.
|
||||||
wantNames := []string{
|
wantNames := []string{
|
||||||
@@ -118,7 +118,7 @@ func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
||||||
|
|
||||||
// No circles => no commands.
|
// No circles => no commands.
|
||||||
require.Empty(t, d.Commands())
|
require.Empty(t, d.Commands())
|
||||||
@@ -149,7 +149,7 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
||||||
|
|
||||||
// There should be at least one AddCircle.
|
// There should be at least one AddCircle.
|
||||||
cmds := d.CommandsByName("AddCircle")
|
cmds := d.CommandsByName("AddCircle")
|
||||||
@@ -161,3 +161,39 @@ func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
|
|||||||
require.Equal(t, 4.0, c.Args[2])
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testi
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawCirclesFromPlan(d, plan, w2.W, w2.H)
|
drawCirclesFromPlan(d, plan, w2.W, w2.H, true)
|
||||||
|
|
||||||
cmds := d.CommandsByName("AddCircle")
|
cmds := d.CommandsByName("AddCircle")
|
||||||
require.Len(t, cmds, len(tt.wantCenters))
|
require.Len(t, cmds, len(tt.wantCenters))
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ package world
|
|||||||
|
|
||||||
// renderLinesStageB performs a full expanded-canvas redraw but renders ONLY Line primitives.
|
// 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.
|
// It uses the Stage A render plan: tiles + per-tile clip + per-tile candidates.
|
||||||
func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error {
|
// func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error {
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
// plan, err := w.buildRenderPlanStageA(params)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return err
|
// return err
|
||||||
}
|
// }
|
||||||
|
|
||||||
drawLinesFromPlan(drawer, plan, w.W, w.H)
|
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
return nil
|
|
||||||
}
|
// 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.
|
// 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.
|
// 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.
|
// 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 {
|
for _, td := range plan.Tiles {
|
||||||
if td.ClipW <= 0 || td.ClipH <= 0 {
|
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -42,7 +44,12 @@ func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH i
|
|||||||
// Collect segments that actually intersect this tile's canonical rect.
|
// Collect segments that actually intersect this tile's canonical rect.
|
||||||
segsToDraw := make([]lineSeg, 0, len(lines))
|
segsToDraw := make([]lineSeg, 0, len(lines))
|
||||||
for _, l := range 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 {
|
for _, s := range segs {
|
||||||
if segmentIntersectsRect(s, td.Tile.Rect) {
|
if segmentIntersectsRect(s, td.Tile.Rect) {
|
||||||
segsToDraw = append(segsToDraw, s)
|
segsToDraw = append(segsToDraw, s)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestDrawLinesFromPlan_WrapX_SplitsAndDrawsInThreeXTiles(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
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:
|
// 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
|
// Each tile group: Save, ClipRect, AddLine(s), Stroke, Restore
|
||||||
@@ -95,7 +95,7 @@ func TestDrawLinesFromPlan_WrapY_SplitsAndDrawsInThreeYTiles(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
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:
|
// Here we expect 3 Y tiles for the central X tile:
|
||||||
// Top partial, middle full (two segments), bottom partial.
|
// 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: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0])
|
||||||
require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1])
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ func (w *World) buildRenderPlanStageA(params RenderParams) (RenderPlan, error) {
|
|||||||
return RenderPlan{}, err
|
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.
|
// Query candidates per tile.
|
||||||
batches, err := w.collectCandidatesForTiles(tiles)
|
batches, err := w.collectCandidatesForTiles(tiles)
|
||||||
|
|||||||
@@ -3,33 +3,35 @@ package world
|
|||||||
import "math"
|
import "math"
|
||||||
|
|
||||||
// renderPointsStageA performs a full expanded-canvas redraw but renders ONLY Point primitives.
|
// renderPointsStageA performs a full expanded-canvas redraw but renders ONLY Point primitives.
|
||||||
func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error {
|
// func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error {
|
||||||
plan, err := w.buildRenderPlanStageA(params)
|
// plan, err := w.buildRenderPlanStageA(params)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return err
|
// return err
|
||||||
}
|
// }
|
||||||
|
|
||||||
style := DefaultRenderStyle()
|
// style := DefaultRenderStyle()
|
||||||
if params.Options != nil && params.Options.Style != nil {
|
// if params.Options != nil && params.Options.Style != nil {
|
||||||
style = *params.Options.Style
|
// style = *params.Options.Style
|
||||||
}
|
// }
|
||||||
|
|
||||||
applyPointStyle(drawer, style)
|
// allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
|
||||||
drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx)
|
|
||||||
return nil
|
// applyPointStyle(drawer, style)
|
||||||
}
|
// drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx, allowWrap)
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
// drawPointsFromPlan keeps backward compatibility for older tests/helpers.
|
// 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.
|
// 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().
|
// Keep it for historical call sites only if they pass through Render().
|
||||||
// Prefer calling drawPointsFromPlanWithRadius with world sizes.
|
// 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,
|
// 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.
|
// 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.
|
// Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed.
|
||||||
rPxInt := int(math.Ceil(radiusPx))
|
rPxInt := int(math.Ceil(radiusPx))
|
||||||
if rPxInt < 0 {
|
if rPxInt < 0 {
|
||||||
@@ -65,7 +67,12 @@ func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, world
|
|||||||
copiesToDraw := make([]pointCopy, 0, len(points))
|
copiesToDraw := make([]pointCopy, 0, len(points))
|
||||||
|
|
||||||
for _, p := range 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 {
|
for _, s := range shifts {
|
||||||
if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
|
if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
|
||||||
copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy})
|
copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy})
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawPointsFromPlan(d, plan)
|
drawPointsFromPlan(d, plan, true)
|
||||||
|
|
||||||
// We expect 4 point copies:
|
// We expect 4 point copies:
|
||||||
// (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1)
|
// (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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawPointsFromPlan(d, plan)
|
drawPointsFromPlan(d, plan, true)
|
||||||
|
|
||||||
// No points => no drawing commands at all.
|
// No points => no drawing commands at all.
|
||||||
require.Empty(t, d.Commands())
|
require.Empty(t, d.Commands())
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testin
|
|||||||
style.PointRadiusPx = 2.0
|
style.PointRadiusPx = 2.0
|
||||||
|
|
||||||
applyPointStyle(d, style)
|
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")
|
cmds := d.CommandsByName("AddPoint")
|
||||||
require.Len(t, cmds, len(tt.wantCenters))
|
require.Len(t, cmds, len(tt.wantCenters))
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func (s *RenderScheduler) runOnUIThread() {
|
|||||||
params := s.latest
|
params := s.latest
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.w.ClampRenderParamsNoWrap(¶ms)
|
||||||
_ = s.w.Render(s.drawer, params) // handle error in real code
|
_ = s.w.Render(s.drawer, params) // handle error in real code
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) {
|
|||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
|
|
||||||
// Execute all three passes over the same plan.
|
// Execute all three passes over the same plan.
|
||||||
drawPointsFromPlan(d, plan)
|
drawPointsFromPlan(d, plan, true)
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
||||||
drawLinesFromPlan(d, plan, w.W, w.H)
|
drawLinesFromPlan(d, plan, w.W, w.H, true)
|
||||||
|
|
||||||
names := d.CommandNames()
|
names := d.CommandNames()
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &fakePrimitiveDrawer{}
|
d := &fakePrimitiveDrawer{}
|
||||||
drawPointsFromPlan(d, plan)
|
drawPointsFromPlan(d, plan, true)
|
||||||
drawCirclesFromPlan(d, plan, w.W, w.H)
|
drawCirclesFromPlan(d, plan, w.W, w.H, true)
|
||||||
|
|
||||||
names := d.CommandNames()
|
names := d.CommandNames()
|
||||||
require.Contains(t, names, "AddPoint")
|
require.Contains(t, names, "AddPoint")
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func shortestWrappedDelta(from, to, size int) (a int, b int) {
|
|||||||
return
|
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.
|
// 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 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
|
// An error is returned when the input is invalid or when rounding would produce
|
||||||
// a non-positive fixed-point zoom.
|
// 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) {
|
if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) {
|
||||||
return 0, errInvalidCameraZoom
|
return 0, errInvalidCameraZoom
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ func cameraZoomToWorldFixed(cameraZoom float64) (int, error) {
|
|||||||
// cameraZoomToWorldFixed. It is intended for internal code paths where invalid
|
// cameraZoomToWorldFixed. It is intended for internal code paths where invalid
|
||||||
// zoom is considered a programmer or integration error and must fail fast.
|
// zoom is considered a programmer or integration error and must fail fast.
|
||||||
func mustCameraZoomToWorldFixed(cameraZoom float64) int {
|
func mustCameraZoomToWorldFixed(cameraZoom float64) int {
|
||||||
zoomFp, err := cameraZoomToWorldFixed(cameraZoom)
|
zoomFp, err := CameraZoomToWorldFixed(cameraZoom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ func TestCameraZoomToWorldFixed(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
|
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tt.want, got)
|
require.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
@@ -347,7 +347,7 @@ func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := cameraZoomToWorldFixed(tt.cameraZoom)
|
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
|
||||||
require.ErrorIs(t, err, errInvalidCameraZoom)
|
require.ErrorIs(t, err, errInvalidCameraZoom)
|
||||||
require.Zero(t, got)
|
require.Zero(t, got)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user