no-wrap option; pivoted exponential zoom
This commit is contained in:
@@ -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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user