package world import ( "image/color" "testing" "github.com/stretchr/testify/require" ) func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { t.Parallel() // World is 10x10 world units => 10000x10000 fixed. w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Circle near origin so that in expanded canvas (bigger than world) // it will appear in multiple torus tiles. id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000 require.NoError(t, err) w.indexObject(w.objects[id]) // Same geometry as points-only test: // viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true) // Expect 4 circle copies, one per tile that covers the expanded canvas. wantNames := []string{ "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", } require.Equal(t, wantNames, d.CommandNames()) // At zoom=1, 1 world unit -> 1 px, so: // circle center at (1,1) => base copy at (3,3) like point test // radius 1 => 1 px // // The rest are shifted by +10px in X and/or Y due to torus tiling. { clip := requireDrawerCommandAt(t, d, 1) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 2, 10, 10) c := requireDrawerCommandAt(t, d, 2) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 3, 3, 1) } { clip := requireDrawerCommandAt(t, d, 6) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 12, 10, 2) c := requireDrawerCommandAt(t, d, 7) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 3, 13, 1) } { clip := requireDrawerCommandAt(t, d, 11) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 2, 2, 10) c := requireDrawerCommandAt(t, d, 12) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 13, 3, 1) } { clip := requireDrawerCommandAt(t, d, 16) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 12, 2, 2) c := requireDrawerCommandAt(t, d, 17) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 13, 13, 1) } } func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Add only a point, no circles. id, err := w.AddPoint(5, 5) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true) // No circles => no commands. require.Empty(t, d.Commands()) } func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) { t.Parallel() w := NewWorld(100, 100) w.resetGrid(10 * SCALE) // radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1. id, err := w.AddCircle(50, 50, 2) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 50 * SCALE, CameraYWorldFp: 50 * SCALE, CameraZoom: 2.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true) // There should be at least one AddCircle. cmds := d.CommandsByName("AddCircle") require.NotEmpty(t, cmds) // All circles in this plan should have radius 4px (2 units * 2x zoom). for _, c := range cmds { require.Len(t, c.Args, 3) 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) } func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) sw := 4.0 circleStyle := w.AddStyleCircle(StyleOverride{ FillColor: color.RGBA{A: 0}, // explicitly transparent StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, StrokeWidthPx: &sw, }) _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100)) 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{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iAdd := indexOfFirstName(cmds, "AddCircle") require.NotEqual(t, -1, iAdd) // After AddCircle we must see Stroke (not Fill). iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds))) iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds))) require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()") require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible") } func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) sw := 2.0 styleID := w.AddStyleCircle(StyleOverride{ FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255}, StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, StrokeWidthPx: &sw, }) _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100)) 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{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iAdd := indexOfFirstName(cmds, "AddCircle") require.NotEqual(t, -1, iAdd) iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds))) iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds))) require.NotEqual(t, -1, iFill, "expected Fill() for visible fill") require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke") require.Less(t, iFill, iStroke, "Stroke must be last when both are visible") }