diff --git a/backend/go.mod b/backend/go.mod index 68e062e..8c3e7e7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( galaxy/model v0.0.0 galaxy/postgres v0.0.0 galaxy/util v0.0.0-00010101000000-000000000000 + github.com/abadojack/whatlanggo v1.0.1 github.com/disciplinedware/go-confusables v0.1.1 github.com/getkin/kin-openapi v0.135.0 github.com/gin-gonic/gin v1.12.0 @@ -36,7 +37,6 @@ require ( ) require ( - github.com/abadojack/whatlanggo v1.0.1 // indirect github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/client/README.md b/client/README.md deleted file mode 100644 index a240f26..0000000 --- a/client/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Client for Galaxy Plus - -UI Client is capable of: - -- Register a new player and login for an existing player using only e-mail and one-time codes, -- Enlist to a new Game from available onboard Games list, -- Request list of Games in which Player participating, -- Request, store and display particular Game data, -- Use push-like mechanism for receiving asynchronous updates from Server, -- Offline mode when no internet connection is available or user desired to work offline. diff --git a/client/appmeta/meta.go b/client/appmeta/meta.go deleted file mode 100644 index cecdfb5..0000000 --- a/client/appmeta/meta.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package appmeta provides shared application metadata used by both the -// bootstrap loader process and the standalone UI client process. -package appmeta - -const ( - // AppID is the shared Fyne application identifier used for a common storage root. - AppID = "GalaxyPlus" - // DefaultBackendURL is the default backend HTTP endpoint used by local runs. - DefaultBackendURL = "http://127.0.0.1:8080" -) diff --git a/client/background.go b/client/background.go deleted file mode 100644 index 67f424e..0000000 --- a/client/background.go +++ /dev/null @@ -1,116 +0,0 @@ -package client - -import ( - "fmt" - "time" - - gerr "galaxy/error" -) - -var ( - checkConnectionInterval = 5 * time.Second - checkVersionInterval = time.Hour - statePersistInterval = time.Second -) - -func (e *client) startBackground() { - if e.conn == nil || e.updater == nil { - return - } - - go e.backgroundLoop() -} - -func (e *client) stopBackground() { - e.backgroundOnce.Do(func() { - close(e.backgroundStop) - }) -} - -func (e *client) backgroundLoop() { - checkConnTimer := time.NewTimer(checkConnectionInterval) - checkVersionTimer := time.NewTimer(checkVersionInterval) - persistStateTimer := time.NewTimer(statePersistInterval) - defer func() { - checkConnTimer.Stop() - checkVersionTimer.Stop() - persistStateTimer.Stop() - }() - - for { - select { - case <-e.backgroundStop: - return - case <-checkConnTimer.C: - if e.conn != nil { - e.OnConnection(e.conn.CheckConnection()) - } - checkConnTimer.Reset(checkConnectionInterval) - case <-checkVersionTimer.C: - if e.updater != nil { - if err := e.updater.CheckAndPrepareLatest(); err != nil { - e.handlerError(err) - } - } - checkVersionTimer.Reset(checkVersionInterval) - case <-persistStateTimer.C: - e.ensureStatePersist() - persistStateTimer.Reset(statePersistInterval) - } - } -} - -func (e *client) ensureStatePersist() { - param := e.GetParams() - needSaving := false - e.stateMu.Lock() - if e.world != nil { - if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom { - e.state.CameraZoom = param.CameraZoom - needSaving = true - } - if param.CameraXWorldFp != e.state.CameraXFp { - e.state.CameraXFp = param.CameraXWorldFp - needSaving = true - } - if param.CameraYWorldFp != e.state.CameraYFp { - e.state.CameraYFp = param.CameraYWorldFp - needSaving = true - } - } - if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset { - e.state.MapSplitterOffset = e.mapSplitter.Offset - needSaving = true - } - if e.accInfo.Open != e.state.AccordionInfoOpen { - e.state.AccordionInfoOpen = e.accInfo.Open - needSaving = true - } - if e.accCalc.Open != e.state.AccordionCalcOpen { - e.state.AccordionCalcOpen = e.accCalc.Open - needSaving = true - } - if needSaving { - if err := e.s.SaveState(*e.state); err != nil { - e.handlerError(err) - } - } - e.stateMu.Unlock() -} - -func (e *client) handlerError(err error) { - if err == nil { - return - } - - fmt.Printf("ERROR: %s\n", err) - - switch { - case gerr.IsConnection(err): - e.OnConnectionError(err) - case gerr.IsStorage(err): - e.OnStorageError(err) - default: - e.OnServiceError(err) - } -} diff --git a/client/background_test.go b/client/background_test.go deleted file mode 100644 index 009155d..0000000 --- a/client/background_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package client - -import ( - "errors" - "testing" - - gerr "galaxy/error" - "github.com/stretchr/testify/require" -) - -func TestHandlerErrorDispatchesByClass(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - err error - wantEvent string - }{ - {name: "connection", err: gerr.WrapConnection(errors.New("dial")), wantEvent: "connection"}, - {name: "storage", err: gerr.WrapStorage(errors.New("write file")), wantEvent: "storage"}, - {name: "service", err: gerr.WrapService(errors.New("bad response")), wantEvent: "service"}, - {name: "unclassified defaults to service", err: errors.New("plain"), wantEvent: "service"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var got string - c := &client{ - onConnectionErrFn: func(error) { got = "connection" }, - onStorageErrFn: func(error) { got = "storage" }, - onServiceErrFn: func(error) { got = "service" }, - } - - c.handlerError(tt.err) - - require.Equal(t, tt.wantEvent, got) - }) - } -} diff --git a/client/canvas.go b/client/canvas.go deleted file mode 100644 index 4b7bb26..0000000 --- a/client/canvas.go +++ /dev/null @@ -1,101 +0,0 @@ -package client - -import ( - "image/color" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/widget" -) - -type interactiveRaster struct { - widget.BaseWidget - - min fyne.Size - raster *canvas.Raster - onLayout func(fyne.Size) - onScrolled func(*fyne.ScrollEvent) - onDragged func(*fyne.DragEvent) - onDragEnd func() - onTapped func(*fyne.PointEvent) -} - -func (r *interactiveRaster) SetMinSize(size fyne.Size) { - r.min = size - r.Resize(size) -} - -func (r *interactiveRaster) MinSize() fyne.Size { - return r.min -} - -func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer { - return &rasterWidgetRender{ - canvas: r, - bg: canvas.NewRasterWithPixels(bgPattern), - onLayout: r.onLayout, - } -} - -// Tapped is a left-click event -func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) { - if r.onTapped == nil { - return - } - r.onTapped(ev) -} - -// TappedSecondary is a right-click event -func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} - -func newInteractiveRaster( - raster *canvas.Raster, - onLayout func(fyne.Size), - onScrolled func(*fyne.ScrollEvent), - onDragged func(*fyne.DragEvent), - onDragEnd func(), - onTapped func(*fyne.PointEvent), -) *interactiveRaster { - r := &interactiveRaster{ - raster: raster, - onLayout: onLayout, - onScrolled: onScrolled, - onDragged: onDragged, - onDragEnd: onDragEnd, - onTapped: onTapped, - } - r.ExtendBaseWidget(r) - return r -} - -func bgPattern(x, y, _, _ int) color.Color { - const boxSize = 25 - - if (x/boxSize)%2 == (y/boxSize)%2 { - return color.Gray{Y: 58} - } - - 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 - } - - r.onDragged(e) -} - -func (r *interactiveRaster) DragEnd() { - if r.onDragEnd == nil { - return - } - r.onDragEnd() -} diff --git a/client/client.go b/client/client.go deleted file mode 100644 index 024a7ee..0000000 --- a/client/client.go +++ /dev/null @@ -1,289 +0,0 @@ -package client - -import ( - "image" - "sync" - - "galaxy/client/updater" - "galaxy/client/widget/calculator" - "galaxy/client/world" - "galaxy/connector" - mc "galaxy/model/client" - "galaxy/storage" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/lang" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -const version = "1.0.0" - -type client struct { - s storage.Storage - conn connector.Connector - app fyne.App - window fyne.Window - - state *mc.State - stateMu sync.RWMutex - - reg *registry - - calculator *calculator.Calculator - mapSplitter *container.Split - accInfo *widget.AccordionItem - accCalc *widget.AccordionItem - - // loadReportFunc func(uint) - - world *world.World - drawer *world.GGDrawer - raster *canvas.Raster - co *RasterCoalescer[world.RenderParams] - pan *PanController - - // Protected camera/options state (UI-facing). This is the "base" params snapshot. - // Viewport/margins are NOT stored here; they come from raster draw callback. - mu sync.RWMutex - wp *world.RenderParams - canvasScale float32 - - // Latest raster geometry metadata for correct event->pixel conversion: - // - logical size: raster.Size() (Fyne units) - // - pixel size: last (wPx,hPx) passed to draw callback - metaMu sync.RWMutex - lastRasterLogicW float32 - lastRasterLogicH float32 - lastRasterPxW int - lastRasterPxH int - lastCanvasScale float32 // optional, useful for debugging - - // Snapshot of params actually used for the last render (includes viewport/margins). - // Used for HitTest and to keep UI interactions consistent with what the user sees. - lastRenderedMu sync.RWMutex - lastRenderedParams world.RenderParams - - // Indexing / backing-canvas caches (owned by client because it depends on UI geometry) - lastIndexedViewportW int - lastIndexedViewportH int - lastIndexedZoomFp int - - lastCanvasW int - lastCanvasH int - - viewportImg *image.RGBA - viewportW int - viewportH int - - hits []world.Hit - - updater *updater.Manager - backgroundStop chan struct{} - backgroundOnce sync.Once - - onConnectionFn func(bool) - onConnectionErrFn func(error) - onStorageErrFn func(error) - onServiceErrFn func(error) -} - -func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) { - e := &client{ - s: s, - conn: conn, - app: app, - window: app.NewWindow("Galaxy Plus"), - reg: newRegistry(), - lastCanvasScale: 1.0, - world: nil, - hits: make([]world.Hit, 5), - backgroundStop: make(chan struct{}), - } - e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass)) - e.updater = updater.NewManager(e.s, e.conn) - - stateExists, err := e.s.StateExists() - if err != nil { - return nil, err - } - if stateExists { - state, err := e.s.LoadState() - if err != nil { - return nil, err - } - e.state = &state - } else { - e.state = &mc.State{ - ClientCurrentVersion: e.Version(), - CameraZoom: 1.0, - MapSplitterOffset: 0.5, - AccordionInfoOpen: false, - AccordionCalcOpen: false, - } - if err := e.s.SaveState(*e.state); err != nil { - return nil, err - } - } - if e.state.CameraZoom <= 0 { - e.state.CameraZoom = 1.0 - } - if e.state.MapSplitterOffset <= 0 { - e.state.MapSplitterOffset = 0.5 - } - e.wp = &world.RenderParams{ - Options: &world.RenderOptions{DisableWrapScroll: false}, - CameraZoom: e.state.CameraZoom, - CameraXWorldFp: e.state.CameraXFp, - CameraYWorldFp: e.state.CameraYFp, - } - - e.drawer = &world.GGDrawer{DC: nil} - - e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image { - return e.draw(wPx, hPx) - }) - - e.pan = NewPanController(e) - - e.co = NewRasterCoalescer( - FyneExecutor{}, - e.raster, - func(wPx, hPx int, p world.RenderParams) image.Image { - return e.renderRasterImage(wPx, hPx, p) - }, - ) - - return e, nil -} - -func (e *client) BuildUI(w fyne.Window) { - mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) - - toolbar := widget.NewToolbar( - widget.NewToolbarAction( - theme.FolderIcon(), - func() { e.initReportAsync("GAME_ID", 0) }), - widget.NewToolbarSeparator(), - widget.NewToolbarAction( - theme.NavigateBackIcon(), - func() {}), - widget.NewToolbarAction( - theme.NavigateNextIcon(), - func() {}), - ) - - e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack()) - e.accInfo.Open = e.state.AccordionInfoOpen - e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject) - e.accCalc.Open = e.state.AccordionCalcOpen - - accordion := widget.NewAccordion() - accordion.MultiOpen = true - accordion.Append(e.accCalc) - accordion.Append(e.accInfo) - - e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion)) - e.mapSplitter.SetOffset(e.state.MapSplitterOffset) - - tabs := container.NewAppTabs( - container.NewTabItemWithIcon( - lang.L("title.map"), - theme.GridIcon(), - e.mapSplitter), - container.NewTabItemWithIcon( - "Calculator", - theme.ComputerIcon(), - container.NewStack(widget.NewButton("Calc", func() {})), - ), - ) - - th := tabs.Theme() - icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo)) - - statusLeft := widget.NewTextGridFromString("Status") - statusAd := widget.NewTextGridFromString("") - - statusBar := container.NewBorder( - nil, // top - nil, // bottom - container.NewHBox(statusLeft, widget.NewSeparator()), // left - container.NewHBox(widget.NewSeparator(), icon), // right - statusAd, // center - ) - - content := container.NewBorder( - toolbar, // top - statusBar, // bottom - nil, // left - nil, // right - tabs, // center - ) - - w.CenterOnScreen() - w.SetContent(content) - s := statusBar.Size() - icon.SetMinSize(fyne.NewSize(s.Height, s.Height)) - e.initLatestReport() -} - -func (e *client) loadWorld(w *world.World) { - if w == nil { - return - } - w.SetCircleRadiusScaleFp(world.SCALE / 1000) - e.world = w - // TODO: store camera position in user settings - e.wp.CameraXWorldFp = w.W / 2 - e.wp.CameraYWorldFp = w.H / 2 - e.world.SetTheme(world.ThemeDark) - - e.RequestRefresh() -} - -func (e *client) Run() error { - e.BuildUI(e.window) - e.startBackground() - e.RequestRefresh() - e.window.SetMaster() - e.window.Resize(fyne.NewSize(800, 600)) - e.window.CenterOnScreen() - e.window.SetOnClosed(e.Shutdown) - e.window.ShowAndRun() - return nil -} - -func (e *client) Shutdown() { - e.stopBackground() - e.ensureStatePersist() - e.window.Close() -} - -// TODO: remove func? -func (e *client) Version() string { return version } - -func (e *client) OnConnection(isGood bool) { - if e.onConnectionFn != nil { - e.onConnectionFn(isGood) - } -} - -func (e *client) OnConnectionError(err error) { - if e.onConnectionErrFn != nil { - e.onConnectionErrFn(err) - } -} - -func (e *client) OnStorageError(err error) { - if e.onStorageErrFn != nil { - e.onStorageErrFn(err) - } -} - -func (e *client) OnServiceError(err error) { - if e.onServiceErrFn != nil { - e.onServiceErrFn(err) - } -} diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 77c1df1..0000000 --- a/client/client_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package client - -import ( - "image" - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -type testExecutor struct { - mu sync.Mutex - queue []func() -} - -func (e *testExecutor) Post(fn func()) { - e.mu.Lock() - e.queue = append(e.queue, fn) - e.mu.Unlock() -} - -func (e *testExecutor) FlushAll() { - for { - var fn func() - e.mu.Lock() - if len(e.queue) > 0 { - fn = e.queue[0] - e.queue = e.queue[1:] - } - e.mu.Unlock() - if fn == nil { - return - } - fn() - } -} - -type testRefresher struct { - mu sync.Mutex - count int -} - -func (r *testRefresher) Refresh() { - r.mu.Lock() - r.count++ - r.mu.Unlock() -} - -func (r *testRefresher) Count() int { - r.mu.Lock() - defer r.mu.Unlock() - return r.count -} - -func TestRasterCoalescer_RequestBeforeDraw_CoalescesToLatest(t *testing.T) { - t.Parallel() - - exec := &testExecutor{} - ref := &testRefresher{} - - var got []int - - co := NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { - got = append(got, p) - return image.NewRGBA(image.Rect(0, 0, w, h)) - }) - - co.Request(1) - co.Request(2) - co.Request(3) - - // Only a single refresh should be scheduled before the next Draw(). - exec.FlushAll() - require.Equal(t, 1, ref.Count()) - - _ = co.Draw(10, 10) - require.Equal(t, []int{3}, got) -} - -func TestRasterCoalescer_RequestDuringDraw_SchedulesOneFollowUpRefresh(t *testing.T) { - t.Parallel() - - exec := &testExecutor{} - ref := &testRefresher{} - var got []int - - var co *RasterCoalescer[int] - co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { - got = append(got, p) - if p == 1 { - co.Request(2) - co.Request(3) - } - return image.NewRGBA(image.Rect(0, 0, w, h)) - }) - - co.Request(1) - - exec.FlushAll() - require.Equal(t, 1, ref.Count()) - - // First draw renders 1 and schedules exactly one additional refresh. - _ = co.Draw(10, 10) - exec.FlushAll() - require.Equal(t, 2, ref.Count()) - - // Second draw renders latest (3). - _ = co.Draw(10, 10) - require.Equal(t, []int{1, 3}, got) -} - -func TestRasterCoalescer_ManyRequestsWhileDrawing_StillOnlyOneExtraRefresh(t *testing.T) { - t.Parallel() - - exec := &testExecutor{} - ref := &testRefresher{} - var got []int - - var co *RasterCoalescer[int] - co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { - got = append(got, p) - if p == 1 { - for i := 2; i <= 50; i++ { - co.Request(i) - } - } - return image.NewRGBA(image.Rect(0, 0, w, h)) - }) - - co.Request(1) - exec.FlushAll() - require.Equal(t, 1, ref.Count()) - - _ = co.Draw(10, 10) - exec.FlushAll() - require.Equal(t, 2, ref.Count()) - - _ = co.Draw(10, 10) - require.Equal(t, []int{1, 50}, got) -} - -func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) { - t.Parallel() - - src := image.NewRGBA(image.Rect(0, 0, 20, 20)) - dst := image.NewRGBA(image.Rect(0, 0, 5, 6)) - - // Fill src with a pattern: pixel (x,y) has RGBA = (x, y, 0, 255). - for y := 0; y < 20; y++ { - for x := 0; x < 20; x++ { - off := y*src.Stride + x*4 - src.Pix[off+0] = byte(x) - src.Pix[off+1] = byte(y) - src.Pix[off+2] = 0 - src.Pix[off+3] = 255 - } - } - - marginX, marginY := 7, 9 - copyViewportRGBA(dst, src, marginX, marginY, 5, 6) - - // Verify a few pixels in dst match the expected source ROI. - // dst(0,0) == src(marginX, marginY) - { - off := 0*dst.Stride + 0*4 - require.Equal(t, byte(marginX), dst.Pix[off+0]) - require.Equal(t, byte(marginY), dst.Pix[off+1]) - require.Equal(t, byte(255), dst.Pix[off+3]) - } - // dst(4,5) == src(marginX+4, marginY+5) - { - off := 5*dst.Stride + 4*4 - require.Equal(t, byte(marginX+4), dst.Pix[off+0]) - require.Equal(t, byte(marginY+5), dst.Pix[off+1]) - require.Equal(t, byte(255), dst.Pix[off+3]) - } - - // Mutate src ROI after copy and ensure dst is unchanged (no aliasing). - { - off := (marginY+0)*src.Stride + (marginX+0)*4 - src.Pix[off+0] = 200 - src.Pix[off+1] = 201 - src.Pix[off+3] = 123 - } - - offDst := 0*dst.Stride + 0*4 - require.Equal(t, byte(marginX), dst.Pix[offDst+0]) - require.Equal(t, byte(marginY), dst.Pix[offDst+1]) - require.Equal(t, byte(255), dst.Pix[offDst+3]) -} - -func TestEventPosToPixel_FloorMapping(t *testing.T) { - t.Parallel() - - e := &client{} - - // Pretend raster logical is 100x50, pixel is 1000x500. - e.metaMu.Lock() - e.lastRasterLogicW = 100 - e.lastRasterLogicH = 50 - e.lastRasterPxW = 1000 - e.lastRasterPxH = 500 - e.metaMu.Unlock() - - x, y, ok := e.eventPosToPixel(0, 0) - require.True(t, ok) - require.Equal(t, 0, x) - require.Equal(t, 0, y) - - // Middle - x, y, ok = e.eventPosToPixel(50, 25) - require.True(t, ok) - require.Equal(t, 500, x) - require.Equal(t, 250, y) - - // Near max logical should map near max pixel with floor. - x, y, ok = e.eventPosToPixel(99.9, 49.9) - require.True(t, ok) - require.GreaterOrEqual(t, x, 998) - require.GreaterOrEqual(t, y, 498) - - // Clamp - x, y, ok = e.eventPosToPixel(-10, 999) - require.True(t, ok) - require.Equal(t, 0, x) - require.Equal(t, 500, y) -} diff --git a/client/cmd/loader/main.go b/client/cmd/loader/main.go deleted file mode 100644 index a27ceb4..0000000 --- a/client/cmd/loader/main.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "galaxy/client" - "galaxy/client/appmeta" - "galaxy/client/loader" - "galaxy/connector/http" - "galaxy/storage/fs" - "os" - "os/signal" - - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/lang" -) - -func main() { - var err error - defer func() { - if err == nil { - if r := recover(); r != nil { - err = errors.Join(err, fmt.Errorf("panic: %v", r)) - } - } - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - }() - - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - app := app.NewWithID(appmeta.AppID) - if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil { - return - } - s, err := fs.NewFS(app.Storage().RootURI().Path()) - if err != nil { - return - } - c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL) - if err != nil { - return - } - l, err := loader.NewLoader(s, c, app) - if err != nil { - return - } - err = l.Run(ctx) -} diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go deleted file mode 100644 index a3c8bcc..0000000 --- a/client/cmd/ui/main.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "galaxy/client" - "galaxy/client/appmeta" - "galaxy/connector/http" - "galaxy/storage/fs" - "os" - "os/signal" - - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/lang" -) - -func main() { - var err error - defer func() { - if err == nil { - if r := recover(); r != nil { - err = errors.Join(err, fmt.Errorf("panic: %v", r)) - } - } - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - }() - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - app := app.NewWithID(appmeta.AppID) - if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil { - return - } - s, err := fs.NewFS(app.Storage().RootURI().Path()) - if err != nil { - return - } - conn, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL) - if err != nil { - return - } - c, err := client.NewClient(s, conn, app) - if err != nil { - return - } - err = c.Run() -} diff --git a/client/coalesce.go b/client/coalesce.go deleted file mode 100644 index 5a7f8a4..0000000 --- a/client/coalesce.go +++ /dev/null @@ -1,133 +0,0 @@ -package client - -/* -Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image). - -Key property: -- draw() renders at most once per invocation (never loops). -- if new requests arrived while drawing, we schedule exactly one extra Refresh. -*/ - -import ( - "image" - "sync" -) - -// UIExecutor posts a function to run on the UI/main thread. -type UIExecutor interface { - Post(fn func()) -} - -// Refresher is the minimal interface we need from fyne.CanvasObject / Raster. -type Refresher interface { - Refresh() -} - -// RasterRenderer renders the latest params and returns an image. -// Must be called on the UI thread (inside draw callback). -type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image - -// RasterCoalescer implements latest-wins coalescing for raster rendering. -// It is designed specifically for toolkits like fyne where the system calls draw(w,h) -// and expects a returned image. -type RasterCoalescer[P any] struct { - exec UIExecutor - refresher Refresher - renderer RasterRenderer[P] - - mu sync.Mutex - - // inDraw == true while Draw() is running on UI thread. - inDraw bool - - // refreshQueued == true when we have already posted a Refresh() that has not yet - // resulted in a Draw() call (or is expected to call Draw soon). - refreshQueued bool - - // pending == true when new params arrived while inDraw==true. - // Draw() will schedule exactly one follow-up Refresh after it returns. - pending bool - - latest P - have bool -} - -// NewRasterCoalescer creates a new coalescer. -// - exec.Post must run fn on UI thread. -// - refresher.Refresh will trigger the framework to call draw(w,h). -func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] { - if exec == nil { - panic("RasterCoalescer: nil executor") - } - if refresher == nil { - panic("RasterCoalescer: nil refresher") - } - if renderer == nil { - panic("RasterCoalescer: nil renderer") - } - return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer} -} - -// Request stores the latest params and schedules exactly one refresh (latest-wins). -// Can be called from any goroutine. -func (c *RasterCoalescer[P]) Request(params P) { - c.mu.Lock() - c.latest = params - c.have = true - - // If we are currently inside Draw(), don't schedule refresh immediately. - // Just mark pending; Draw() will schedule one follow-up refresh after it returns. - if c.inDraw { - c.pending = true - c.mu.Unlock() - return - } - - // Not drawing. Schedule at most one refresh until the next Draw() happens. - if c.refreshQueued { - c.mu.Unlock() - return - } - - c.refreshQueued = true - c.mu.Unlock() - - c.exec.Post(c.refresher.Refresh) -} - -// Draw must be called from the raster draw callback on the UI thread. -// It renders exactly once with the latest snapshot. -// If more requests arrived while drawing, it schedules exactly one extra refresh. -func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image { - c.mu.Lock() - // A Draw call corresponds to a previously scheduled refresh being serviced. - c.refreshQueued = false - - if !c.have { - c.mu.Unlock() - return image.NewRGBA(image.Rect(0, 0, wPx, hPx)) - } - - c.inDraw = true - c.pending = false - params := c.latest - c.mu.Unlock() - - img := c.renderer(wPx, hPx, params) - - c.mu.Lock() - needAnother := c.pending - c.pending = false - c.inDraw = false - - // If we need another frame, schedule exactly one refresh (if not already queued). - if needAnother && !c.refreshQueued { - c.refreshQueued = true - c.mu.Unlock() - c.exec.Post(c.refresher.Refresh) - return img - } - - c.mu.Unlock() - return img -} diff --git a/client/embed.go b/client/embed.go deleted file mode 100644 index ba4ddee..0000000 --- a/client/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package client - -import "embed" - -//go:embed resource/lang -var Translations embed.FS diff --git a/client/go.mod b/client/go.mod deleted file mode 100644 index 6e95bbc..0000000 --- a/client/go.mod +++ /dev/null @@ -1,46 +0,0 @@ -module galaxy/client - -go 1.26.0 - -require ( - fyne.io/fyne/v2 v2.7.3 - github.com/fogleman/gg v1.3.0 - github.com/stretchr/testify v1.11.1 -) - -require ( - fyne.io/systray v1.12.0 // indirect - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fredbi/uri v1.1.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fyne-io/gl-js v0.2.0 // indirect - github.com/fyne-io/glfw-js v0.3.0 // indirect - github.com/fyne-io/image v0.1.1 // indirect - github.com/fyne-io/oksvg v0.2.0 // indirect - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect - github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.3.3 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect - github.com/hack-pad/go-indexeddb v0.3.2 // indirect - github.com/hack-pad/safejs v0.1.1 // indirect - github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect - github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/rymdport/portal v0.4.2 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect - github.com/yuin/goldmark v1.7.16 // indirect - golang.org/x/image v0.36.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/client/go.sum b/client/go.sum deleted file mode 100644 index cc38db8..0000000 --- a/client/go.sum +++ /dev/null @@ -1,82 +0,0 @@ -fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE= -fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw= -fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= -fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= -github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= -github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= -github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= -github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= -github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= -github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= -github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= -github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= -github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= -github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= -github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc= -github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts= -github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs= -github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= -github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= -github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= -github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= -github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= -github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= -github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= -golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/client/hit.go b/client/hit.go deleted file mode 100644 index 93beed0..0000000 --- a/client/hit.go +++ /dev/null @@ -1,61 +0,0 @@ -package client - -import ( - "galaxy/client/world" - - "fyne.io/fyne/v2" -) - -var m = func(v int) float64 { return float64(v) / float64(world.SCALE) } - -func (e *client) onTapped(ev *fyne.PointEvent) { - if e.world == nil || ev == nil { - return - } - - xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) - if !ok { - return - } - - params := e.getLastRenderedParams() - hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) - if err != nil { - e.handlerError(err) - return - } - - if len(hits) == 0 { - e.calculator.UnloadPlanet() - return - } - - for i := range hits { - e.onHit(hits[i]) - } -} - -func (e *client) onHit(hit world.Hit) { - // var coord string - // if hit.Kind == world.KindLine { - // coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) - // } else { - // coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) - // } - // fmt.Println("hit:", hit.ID, "Coord:", coord) - switch hit.Kind { - case world.KindPoint: - case world.KindCircle: - e.onHitCircle(hit.ID) - case world.KindLine: - } -} - -func (e *client) onHitCircle(id world.PrimitiveID) { - p, ok := e.reg.localPlanet(id) - if !ok { - return - } - e.calculator.LoadPlanet(p.Name, p.Number, p.FreeIndustry.F(), p.Material.F(), p.Resources.F()) - e.calculator.Refresh() -} diff --git a/client/loader/loader.go b/client/loader/loader.go deleted file mode 100644 index 60c2285..0000000 --- a/client/loader/loader.go +++ /dev/null @@ -1,214 +0,0 @@ -package loader - -import ( - "context" - "errors" - "fmt" - "sync" - - "galaxy/client/updater" - "galaxy/connector" - "galaxy/storage" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -const ( - loaderLogViewportColumns = 80 - loaderLogViewportRows = 12 -) - -type loader struct { - app fyne.App - storage storage.Storage - connector connector.Connector - updater *updater.Manager - runner uiRunner - debugWindow fyne.Window - textGrid *widget.TextGrid - btn *widget.Button - - ctx context.Context - - resultMu sync.Mutex - result error - - closeMu sync.Mutex - closeQuits bool -} - -// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size -// from the active Fyne text metrics. -func loaderLogViewportMinSize(app fyne.App) fyne.Size { - if app == nil || app.Driver() == nil { - return fyne.NewSize(0, 0) - } - - cellSize, _ := app.Driver().RenderedTextSize( - "M", - theme.TextSize(), - fyne.TextStyle{Monospace: true}, - nil, - ) - - return fyne.NewSize( - cellSize.Width*loaderLogViewportColumns, - cellSize.Height*loaderLogViewportRows, - ) -} - -func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) { - l := &loader{ - app: app, - connector: conn, - storage: s, - updater: updater.NewManager(s, conn), - runner: execRunner{}, - textGrid: widget.NewTextGrid(), - debugWindow: app.NewWindow("Loader"), - } - l.btn = widget.NewButton("Retry", l.onButtonAction) - l.btn.Disable() - l.textGrid.Scroll = fyne.ScrollNone - l.debugWindow.SetCloseIntercept(l.onWindowClose) - - logScroll := container.NewScroll(l.textGrid) - logScroll.Direction = container.ScrollBoth - logScroll.SetMinSize(loaderLogViewportMinSize(app)) - - actionBar := container.NewCenter(container.NewHBox(l.btn)) - - content := container.NewBorder(nil, actionBar, nil, nil, logScroll) - l.debugWindow.SetContent(content) - l.debugWindow.Resize(content.MinSize()) - l.debugWindow.SetFixedSize(true) - l.debugWindow.CenterOnScreen() - - return l, nil -} - -func (l *loader) runOnce(ctx context.Context) error { - target, err := l.updater.EnsureLaunchTarget() - if err != nil { - return err - } - - l.logText(fmt.Sprintf("Starting UI client v%s", target.Version)) - l.logText(fmt.Sprintf("Executable: %s", target.Path)) - - exitCode, runErr := l.runner.Run(ctx, target.Path) - markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr) - - switch { - case runErr != nil: - return errors.Join(fmt.Errorf("launch UI client v%s: %w", target.Version, runErr), markErr) - case exitCode != 0: - return errors.Join(fmt.Errorf("UI client v%s exited with code %d", target.Version, exitCode), markErr) - default: - return markErr - } -} - -// init prepares and launches the standalone UI client, or shows a retry button on failure. -func (l *loader) init(ctx context.Context) { - l.setCloseQuits(false) - fyne.Do(func() { - l.textGrid.SetText("") - l.btn.Hide() - l.btn.Disable() - // show debugWindow can be done with future debug mode, e.g. with -debug flag - l.debugWindow.Hide() - }) - - err := l.runOnce(ctx) - if err == nil || errors.Is(err, context.Canceled) { - l.setResult(nil) - fyne.Do(func() { - l.debugWindow.Hide() - l.app.Quit() - }) - return - } - - l.setCloseQuits(true) - l.setResult(err) - l.logError(err) - fyne.Do(func() { - l.btn.SetText("Retry") - l.btn.Enable() - l.btn.Show() - l.debugWindow.Show() - }) -} - -func (l *loader) onButtonAction() { - if l.ctx == nil { - return - } - go l.init(l.ctx) -} - -func (l *loader) onWindowClose() { - if l.getCloseQuits() { - l.app.Quit() - return - } - - l.debugWindow.Hide() -} - -func (l *loader) logText(v string) { - if l.textGrid == nil { - return - } - fyne.Do(func() { l.textGrid.Append(v) }) -} - -func (l *loader) logError(err error) { - l.logText(fmt.Sprintf("ERROR: %s", err)) -} - -func (l *loader) setResult(err error) { - l.resultMu.Lock() - defer l.resultMu.Unlock() - l.result = err -} - -func (l *loader) getResult() error { - l.resultMu.Lock() - defer l.resultMu.Unlock() - return l.result -} - -func (l *loader) setCloseQuits(v bool) { - l.closeMu.Lock() - defer l.closeMu.Unlock() - l.closeQuits = v -} - -func (l *loader) getCloseQuits() bool { - l.closeMu.Lock() - defer l.closeMu.Unlock() - return l.closeQuits -} - -// Run starts the loader window, launches the standalone UI process, and returns -// the final launch result once the loader application exits. -func (l *loader) Run(ctx context.Context) error { - l.ctx = ctx - - go l.init(ctx) - go func() { - <-ctx.Done() - fyne.Do(l.app.Quit) - }() - - l.app.Run() - if errors.Is(ctx.Err(), context.Canceled) { - return nil - } - return l.getResult() -} diff --git a/client/loader/loader_test.go b/client/loader/loader_test.go deleted file mode 100644 index 40121ea..0000000 --- a/client/loader/loader_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package loader - -import ( - "context" - "errors" - "path/filepath" - "testing" - - "galaxy/client/updater" - "galaxy/connector" - mc "galaxy/model/client" - "galaxy/model/report" - "galaxy/storage" - "galaxy/storage/fs" - - "github.com/stretchr/testify/require" -) - -type stubConnector struct { - versions []connector.VersionInfo - versionErr error - downloads map[string][]byte - downloadErr error -} - -func (c *stubConnector) CheckConnection() bool { - return true -} - -func (c *stubConnector) CheckVersion() ([]connector.VersionInfo, error) { - if c.versionErr != nil { - return nil, c.versionErr - } - return c.versions, nil -} - -func (c *stubConnector) DownloadVersion(url string) ([]byte, error) { - if c.downloadErr != nil { - return nil, c.downloadErr - } - data, ok := c.downloads[url] - if !ok { - return nil, errors.New("missing download payload") - } - return data, nil -} - -func (c *stubConnector) FetchReport(mc.GameID, uint, func(report.Report, error)) {} - -type stubRunner struct { - paths []string - exitCode int - err error -} - -func (r *stubRunner) Run(_ context.Context, path string) (int, error) { - r.paths = append(r.paths, path) - return r.exitCode, r.err -} - -func TestRunOnceFirstLaunchDownloadsAndPromotesVersion(t *testing.T) { - t.Parallel() - - s := newTestStorage(t) - payload := []byte("ui-binary-1.2.3") - info := connector.VersionInfo{ - OS: "windows", - Arch: "amd64", - Kind: connector.ArtifactKindExecutable, - Version: "1.2.3", - URL: "https://example.com/ui-1.2.3.exe", - Checksum: connector.NewSHA256Digest(payload), - } - conn := &stubConnector{ - versions: []connector.VersionInfo{info}, - downloads: map[string][]byte{info.URL: payload}, - } - runner := &stubRunner{} - l := &loader{ - storage: s, - connector: conn, - updater: updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")), - runner: runner, - } - - err := l.runOnce(context.Background()) - require.NoError(t, err) - - state, err := s.LoadState() - require.NoError(t, err) - require.Equal(t, "1.2.3", state.ClientCurrentVersion) - require.Nil(t, state.ClientNextVersion) - - expectedPath := filepath.Join(s.StorageRoot(), updater.ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable)) - require.Equal(t, []string{expectedPath}, runner.paths) -} - -func TestRunOnceSpawnFailureClearsPendingAndKeepsCurrent(t *testing.T) { - t.Parallel() - - s := newTestStorage(t) - currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable) - require.NoError(t, s.WriteFile(currentPath, []byte("current"))) - require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"})) - - payload := []byte("ui-binary-1.1.0") - info := connector.VersionInfo{ - OS: "windows", - Arch: "amd64", - Kind: connector.ArtifactKindExecutable, - Version: "1.1.0", - URL: "https://example.com/ui-1.1.0.exe", - Checksum: connector.NewSHA256Digest(payload), - } - conn := &stubConnector{ - versions: []connector.VersionInfo{info}, - downloads: map[string][]byte{info.URL: payload}, - } - manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")) - require.NoError(t, manager.CheckAndPrepareLatest()) - - l := &loader{ - storage: s, - connector: conn, - updater: manager, - runner: &stubRunner{ - err: errors.New("spawn failed"), - }, - } - - err := l.runOnce(context.Background()) - require.Error(t, err) - - state, err := s.LoadState() - require.NoError(t, err) - require.Equal(t, "1.0.0", state.ClientCurrentVersion) - require.Nil(t, state.ClientNextVersion) - - currentExists, _, err := s.FileExists(currentPath) - require.NoError(t, err) - require.True(t, currentExists) - - nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable)) - require.NoError(t, err) - require.False(t, nextExists) -} - -func TestRunOnceNonZeroExitClearsPendingAndKeepsCurrent(t *testing.T) { - t.Parallel() - - s := newTestStorage(t) - currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable) - require.NoError(t, s.WriteFile(currentPath, []byte("current"))) - require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"})) - - payload := []byte("ui-binary-1.1.0") - info := connector.VersionInfo{ - OS: "windows", - Arch: "amd64", - Kind: connector.ArtifactKindExecutable, - Version: "1.1.0", - URL: "https://example.com/ui-1.1.0.exe", - Checksum: connector.NewSHA256Digest(payload), - } - conn := &stubConnector{ - versions: []connector.VersionInfo{info}, - downloads: map[string][]byte{info.URL: payload}, - } - manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")) - require.NoError(t, manager.CheckAndPrepareLatest()) - - l := &loader{ - storage: s, - connector: conn, - updater: manager, - runner: &stubRunner{ - exitCode: 23, - }, - } - - err := l.runOnce(context.Background()) - require.Error(t, err) - - state, err := s.LoadState() - require.NoError(t, err) - require.Equal(t, "1.0.0", state.ClientCurrentVersion) - require.Nil(t, state.ClientNextVersion) - - nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable)) - require.NoError(t, err) - require.False(t, nextExists) -} - -func newTestStorage(t *testing.T) *testStorage { - t.Helper() - - root := t.TempDir() - s, err := fs.NewFS(root) - require.NoError(t, err) - - return &testStorage{Storage: s, root: root} -} - -type testStorage struct { - storage.Storage - root string -} - -func (s *testStorage) StorageRoot() string { - return s.root -} diff --git a/client/loader/loader_ui_test.go b/client/loader/loader_ui_test.go deleted file mode 100644 index a11a2f7..0000000 --- a/client/loader/loader_ui_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package loader - -import ( - "fmt" - "testing" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - fynetest "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/theme" - - "github.com/stretchr/testify/require" -) - -func TestNewLoaderConfiguresWindowGeometry(t *testing.T) { - app := fynetest.NewApp() - spy := &spyApp{App: app} - - l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy) - require.NoError(t, err) - - require.NotNil(t, spy.window) - require.Same(t, spy.window, l.debugWindow) - require.True(t, spy.window.setContentCalled) - require.True(t, spy.window.resizeCalled) - require.Equal(t, spy.window.content.MinSize(), spy.window.resizeSize) - require.True(t, spy.window.fixedSizeCalled) - require.True(t, spy.window.fixedSize) - require.True(t, spy.window.centerOnScreenCalled) -} - -func TestNewLoaderBuildsScrollableBorderLayout(t *testing.T) { - app := fynetest.NewApp() - - l, err := NewLoader(newTestStorage(t), &stubConnector{}, app) - require.NoError(t, err) - - content, ok := l.debugWindow.Content().(*fyne.Container) - require.True(t, ok) - require.Equal(t, "*layout.borderLayout", fmt.Sprintf("%T", content.Layout)) - require.Len(t, content.Objects, 2) - - logScroll, ok := content.Objects[0].(*container.Scroll) - require.True(t, ok) - require.Same(t, l.textGrid, logScroll.Content) - require.Equal(t, container.ScrollBoth, logScroll.Direction) - require.Equal(t, loaderLogViewportMinSize(app), logScroll.MinSize()) - require.Equal(t, fyne.ScrollNone, l.textGrid.Scroll) - - actionBar, ok := content.Objects[1].(*fyne.Container) - require.True(t, ok) - require.Len(t, actionBar.Objects, 1) - - actionRow, ok := actionBar.Objects[0].(*fyne.Container) - require.True(t, ok) - require.Len(t, actionRow.Objects, 1) - require.Same(t, l.btn, actionRow.Objects[0]) - - content.Resize(content.MinSize()) - - require.Equal(t, fyne.NewPos(0, 0), logScroll.Position()) - require.Equal(t, content.Size().Width, logScroll.Size().Width) - require.Equal( - t, - content.Size().Height-actionBar.MinSize().Height-theme.Padding(), - logScroll.Size().Height, - ) - - require.Equal( - t, - fyne.NewPos(0, content.Size().Height-actionBar.MinSize().Height), - actionBar.Position(), - ) - require.Equal(t, content.Size().Width, actionBar.Size().Width) - require.Equal(t, actionRow.MinSize().Width, actionRow.Size().Width) - require.Equal(t, l.btn.MinSize().Width, l.btn.Size().Width) - require.Equal(t, l.btn.MinSize().Height, l.btn.Size().Height) - require.Equal(t, (content.Size().Width-actionRow.Size().Width)/2, actionRow.Position().X) -} - -func TestNewLoaderInterceptsWindowCloseByHidingWindow(t *testing.T) { - app := fynetest.NewApp() - spy := &spyApp{App: app} - - l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy) - require.NoError(t, err) - - require.NotNil(t, spy.window) - require.Same(t, spy.window, l.debugWindow) - require.NotNil(t, spy.window.closeIntercept) - - spy.window.closeIntercept() - - require.Equal(t, 1, spy.window.hideCalls) - require.Zero(t, spy.window.closeCalls) - require.Zero(t, spy.quitCalls) -} - -func TestLoaderWindowCloseQuitsApplicationAfterLaunchFailure(t *testing.T) { - app := fynetest.NewApp() - spy := &spyApp{App: app} - - l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy) - require.NoError(t, err) - - l.setCloseQuits(true) - spy.window.closeIntercept() - - require.Zero(t, spy.window.hideCalls) - require.Zero(t, spy.window.closeCalls) - require.Equal(t, 1, spy.quitCalls) -} - -type spyApp struct { - fyne.App - window *spyWindow - quitCalls int -} - -func (a *spyApp) NewWindow(title string) fyne.Window { - a.window = &spyWindow{Window: a.App.NewWindow(title)} - return a.window -} - -func (a *spyApp) Quit() { - a.quitCalls++ - a.App.Quit() -} - -type spyWindow struct { - fyne.Window - - content fyne.CanvasObject - closeIntercept func() - resizeSize fyne.Size - hideCalls int - closeCalls int - setContentCalled bool - resizeCalled bool - fixedSize bool - fixedSizeCalled bool - centerOnScreenCalled bool -} - -func (w *spyWindow) CenterOnScreen() { - w.centerOnScreenCalled = true - w.Window.CenterOnScreen() -} - -func (w *spyWindow) Close() { - w.closeCalls++ - w.Window.Close() -} - -func (w *spyWindow) Hide() { - w.hideCalls++ - w.Window.Hide() -} - -func (w *spyWindow) Resize(size fyne.Size) { - w.resizeCalled = true - w.resizeSize = size - w.Window.Resize(size) -} - -func (w *spyWindow) SetContent(content fyne.CanvasObject) { - w.setContentCalled = true - w.content = content - w.Window.SetContent(content) -} - -func (w *spyWindow) SetCloseIntercept(callback func()) { - w.closeIntercept = callback - w.Window.SetCloseIntercept(callback) -} - -func (w *spyWindow) SetFixedSize(fixed bool) { - w.fixedSizeCalled = true - w.fixedSize = fixed - w.Window.SetFixedSize(fixed) -} diff --git a/client/loader/runner.go b/client/loader/runner.go deleted file mode 100644 index 30f9705..0000000 --- a/client/loader/runner.go +++ /dev/null @@ -1,34 +0,0 @@ -package loader - -import ( - "context" - "errors" - "os" - "os/exec" -) - -// uiRunner executes the standalone UI artifact and returns its exit code. -type uiRunner interface { - Run(context.Context, string) (int, error) -} - -type execRunner struct{} - -func (execRunner) Run(ctx context.Context, path string) (int, error) { - cmd := exec.CommandContext(ctx, path) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - err := cmd.Run() - if err == nil { - return 0, nil - } - - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return exitErr.ExitCode(), nil - } - - return -1, err -} diff --git a/client/loader/util.go b/client/loader/util.go deleted file mode 100644 index 83857f4..0000000 --- a/client/loader/util.go +++ /dev/null @@ -1,14 +0,0 @@ -package loader - -import "crypto/sha256" - -// SumSHA256 calculates SHA-256 for the provided byte slice and returns -// the raw 32-byte digest as a fixed-size array. -func SumSHA256(data []byte) [32]byte { - return sha256.Sum256(data) -} - -// EqualSHA256 returns true when both SHA-256 digests are identical. -func EqualSHA256(a, b [32]byte) bool { - return a == b -} diff --git a/client/loader/util_test.go b/client/loader/util_test.go deleted file mode 100644 index 3f63a77..0000000 --- a/client/loader/util_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package loader - -import ( - "crypto/sha256" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestSumSHA256 verifies that SumSHA256 returns the same digest -// as the standard library implementation for a non-empty payload. -func TestSumSHA256(t *testing.T) { - t.Parallel() - - data := []byte("hello world") - expected := sha256.Sum256(data) - - actual := SumSHA256(data) - - require.Equal(t, expected, actual) -} - -// TestSumSHA256Empty verifies that SumSHA256 correctly handles -// an empty byte slice. -func TestSumSHA256Empty(t *testing.T) { - t.Parallel() - - data := []byte{} - expected := sha256.Sum256(data) - - actual := SumSHA256(data) - - require.Equal(t, expected, actual) -} - -// TestEqualSHA256Same verifies that two identical digests -// are considered equal. -func TestEqualSHA256Same(t *testing.T) { - t.Parallel() - - data := []byte("hello") - digest := sha256.Sum256(data) - - require.True(t, EqualSHA256(digest, digest)) -} - -// TestEqualSHA256Different verifies that different digests -// are considered not equal. -func TestEqualSHA256Different(t *testing.T) { - t.Parallel() - - digestA := sha256.Sum256([]byte("hello")) - digestB := sha256.Sum256([]byte("world")) - - require.False(t, EqualSHA256(digestA, digestB)) -} diff --git a/client/mock.go b/client/mock.go deleted file mode 100644 index 7c8bbe1..0000000 --- a/client/mock.go +++ /dev/null @@ -1,48 +0,0 @@ -package client - -import ( - "image/color" - - "galaxy/client/world" -) - -func mockWorld() *world.World { - w := world.NewWorld(300, 300) - mockWorldInit(w) - return w -} - -func mockWorldInit(w *world.World) { - lineStyle := w.AddStyleLine(world.StyleOverride{ - StrokeColor: color.RGBA{R: 0, G: 255, B: 0, A: 255}, - StrokeWidthPx: new(3.0), - StrokeDashes: new([]float64{10.}), - }) - - if _, err := w.AddCircle(150, 150, 50); err != nil { - panic(err) - } - - if _, err := w.AddCircle(150, 299, 30); err != nil { - panic(err) - } - if _, err := w.AddCircle(299, 150, 30); err != nil { - panic(err) - } - - if _, err := w.AddLine(100, 20, 200, 30, world.LineWithStyleID(lineStyle), world.LineWithPriority(500)); err != nil { - panic(err) - } - - if _, err := w.AddLine(50, 50, 250, 100); err != nil { - panic(err) - } - - if _, err := w.AddPoint(10, 10); err != nil { - panic(err) - } - - if _, err := w.AddPoint(25, 255); err != nil { - panic(err) - } -} diff --git a/client/registry.go b/client/registry.go deleted file mode 100644 index c61b146..0000000 --- a/client/registry.go +++ /dev/null @@ -1,76 +0,0 @@ -package client - -import ( - "galaxy/client/world" - "galaxy/model/report" -) - -const ( - entityClassUnknown int = iota - 1 - entityClassLocalPlanet - entityClassOthersPlanet - entityClassFreePlanet - entityClassUnidentifiedPlanet -) - -type registry struct { - report *report.Report - localPlanetIndex map[world.PrimitiveID]int - unidentifiedPlanetIndex map[world.PrimitiveID]int -} - -func newRegistry() *registry { - return ®istry{ - localPlanetIndex: make(map[world.PrimitiveID]int), - unidentifiedPlanetIndex: make(map[world.PrimitiveID]int), - } -} - -func (r *registry) clear(report *report.Report) { - r.report = report - clear(r.localPlanetIndex) - clear(r.unidentifiedPlanetIndex) -} - -func (r *registry) entityClass(id world.PrimitiveID) int { - if r.isLocalPlanet(id) { - return entityClassLocalPlanet - } - if r.isUnidentifiedPlanet(id) { - return entityClassUnidentifiedPlanet - } - return entityClassUnknown -} - -func (r *registry) registerLocalPlanet(id world.PrimitiveID, index int) { - r.localPlanetIndex[id] = index -} - -func (r *registry) isLocalPlanet(id world.PrimitiveID) bool { - _, ok := r.localPlanetIndex[id] - return ok -} - -func (r *registry) localPlanet(id world.PrimitiveID) (*report.LocalPlanet, bool) { - i, ok := r.localPlanetIndex[id] - if !ok { - return nil, false - } - if i > len(r.report.LocalPlanet)-1 { - return nil, false - } - return &r.report.LocalPlanet[i], true -} - -func (r *registry) registerUnidentifiedPlanet(id world.PrimitiveID, index int) { - r.unidentifiedPlanetIndex[id] = index -} - -func (r *registry) isUnidentifiedPlanet(id world.PrimitiveID) bool { - _, ok := r.unidentifiedPlanetIndex[id] - return ok -} - -func (c *client) createShipClass(n string, D float64, A uint, W float64, S float64, C float64) { - -} diff --git a/client/report.go b/client/report.go deleted file mode 100644 index 60e6646..0000000 --- a/client/report.go +++ /dev/null @@ -1,119 +0,0 @@ -package client - -import ( - "fmt" - "galaxy/client/widget/calculator" - "galaxy/client/world" - mc "galaxy/model/client" - "galaxy/model/report" - "slices" - - "fyne.io/fyne/v2" -) - -func (e *client) initLatestReport() { - e.stateMu.Lock() - if e.state.ActiveGameID != nil { - if stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == *e.state.ActiveGameID }); stateIdx >= 0 { - e.initReportAsync(*e.state.ActiveGameID, e.state.GameState[stateIdx].ActiveTurn) - } - } - e.stateMu.Unlock() -} - -func (e *client) initReportAsync(gid mc.GameID, t uint) { - e.s.ReportExistsAsync(gid, t, func(b bool, err error) { e.reportAtStorageExists(gid, t, b, err) }) -} - -func (e *client) reportAtStorageExists(gid mc.GameID, t uint, exists bool, err error) { - if err != nil { - e.handlerError(err) - return - } - if exists { - e.s.LoadReportAsync(gid, t, func(r report.Report, err error) { e.loadReportHandler(gid, r, err) }) - return - } - e.conn.FetchReport(gid, t, func(r report.Report, err error) { e.fetchReportHandler(gid, r, err) }) -} - -func (e *client) fetchReportHandler(gid mc.GameID, r report.Report, err error) { - if err != nil { - e.handlerError(err) - return - } - - e.s.SaveReportAsync(gid, r.Turn, r, func(err error) { e.loadReportHandler(gid, r, err) }) -} - -func (e *client) loadReportHandler(gid mc.GameID, r report.Report, err error) { - if err != nil { - e.handlerError(err) - return - } - - e.stateMu.Lock() - needSaveState := false - stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == gid }) - if stateIdx < 0 { - e.state.GameState = append(e.state.GameState, mc.GameState{ID: gid, LastTurn: r.Turn, ActiveTurn: r.Turn}) - stateIdx = len(e.state.GameState) - 1 - needSaveState = true - } - if e.state.ActiveGameID == nil { - e.state.ActiveGameID = new(gid) - needSaveState = true - } - if e.state.GameState[stateIdx].LastTurn < r.Turn { - e.state.GameState[stateIdx].LastTurn = r.Turn - e.state.GameState[stateIdx].ActiveTurn = r.Turn - needSaveState = true - } - if needSaveState { - if err := e.s.SaveState(*e.state); err != nil { - e.handlerError(err) - return - } - } - e.stateMu.Unlock() - - e.setReport(r) -} - -func (e *client) setReport(r report.Report) { - w := world.NewWorld(int(r.Width), int(r.Height)) - e.reg.clear(&r) - for i := range r.LocalPlanet { - p := r.LocalPlanet[i] - id, err := w.AddCircle(p.X.F(), p.Y.F(), p.Size.F(), world.CircleWithClass(world.CircleClassLocalPlanet)) - if err != nil { - e.handlerError(err) - return - } - e.reg.registerLocalPlanet(id, i) - } - for i := range r.UnidentifiedPlanet { - p := r.UnidentifiedPlanet[i] - id, err := w.AddPoint(p.X.F(), p.Y.F(), world.PointWithClass(world.PointClassTrackIncoming)) - if err != nil { - e.handlerError(err) - return - } - e.reg.registerUnidentifiedPlanet(id, i) - } - e.loadWorld(w) - - selfIdx := slices.IndexFunc(r.Player, func(p report.Player) bool { return p.Name == r.Race }) - if selfIdx >= 0 { - fyne.Do(func() { - e.calculator.Init( - calculator.WithPlayerDrives(r.Player[selfIdx].Drive.F()), - calculator.WithPlayerWeapons(r.Player[selfIdx].Weapons.F()), - calculator.WithPlayerShields(r.Player[selfIdx].Shields.F()), - calculator.WithPlayerCargo(r.Player[selfIdx].Cargo.F()), - ) - }) - } else { - e.OnServiceError(fmt.Errorf("race %q not found at report players list", r.Race)) - } -} diff --git a/client/resource/lang/en.json b/client/resource/lang/en.json deleted file mode 100644 index 5dab7ee..0000000 --- a/client/resource/lang/en.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "title": { - "map": "Map", - "calculator": "Ship Calculator", - "info": "Info" - }, - "planet": { - "title": "Planet #{{.Number}} '{{.Name}}' production fot this ship:", - "mat": "Materials", - "prod.mass": "Prod. Mass", - "prod.ships": "Ships" - }, - "tech": { - "d": "Drive", - "w": "Weapons", - "s": "Shields", - "c": "Cargo" - }, - "ship": { - "action.create": "Create", - "mass": "Mass", - "speed": "Speed", - "attack": "Attack", - "defense": "Defense", - "load": "Load" - }, - "label": { - "max": "Max." - } -} \ No newline at end of file diff --git a/client/ui.go b/client/ui.go deleted file mode 100644 index 8e76a53..0000000 --- a/client/ui.go +++ /dev/null @@ -1,334 +0,0 @@ -package client - -import ( - "image" - "math" - - "galaxy/client/world" - - "fyne.io/fyne/v2" - "github.com/fogleman/gg" -) - -/* -Fyne integration notes: - -- canvas.NewRaster calls draw(w,h) on the UI thread. -- We MUST keep draw() cheap and never loop re-rendering inside it. -- Coalescing must therefore schedule refreshes and render at most once per draw call. -- The world renderer expects: - - RenderParams.ViewportWidthPx/HeightPx: the size of the visible viewport. - - RenderParams.MarginXPx/MarginYPx: margins around viewport. - - RenderParams.CameraXWorldFp/YWorldFp: camera center in world-fixed units. - - RenderParams.CameraZoom: float zoom (converted inside world). -- world.Render draws on the full expanded canvas (viewport + 2*margins on each axis). - -This adapter enforces: -- viewport sizes come from draw(w,h) -- margins are computed from viewport sizes (w/4 and h/4) -- gg context backing image is resized to the expanded canvas size -- IndexOnViewportChange is called when viewport sizes changed (you can also include zoom if desired) -*/ - -var ( - blankImage image.Image = image.NewRGBA(image.Rect(0, 0, 0, 0)) -) - -// FyneExecutor posts functions onto the Fyne UI thread. -type FyneExecutor struct{} - -func (FyneExecutor) Post(fn func()) { - fyne.Do(fn) -} - -// GetParams returns a copy of current render params for external reads. -func (e *client) GetParams() world.RenderParams { - e.mu.RLock() - defer e.mu.RUnlock() - return *e.wp -} - -// UpdateParams applies a modification function to render params and schedules a refresh. -// This is a safe way to mutate camera/zoom from event handlers. -func (e *client) UpdateParams(fn func(p *world.RenderParams)) { - e.mu.Lock() - fn(e.wp) - p := *e.wp - e.mu.Unlock() - - e.co.Request(p) -} - -// RequestRefresh schedules a refresh with the current params snapshot. -// Useful if you changed world objects and want to redraw. -func (e *client) RequestRefresh() { - e.mu.RLock() - p := *e.wp - e.mu.RUnlock() - e.co.Request(p) -} - -// draw is the raster callback. It must be cheap and must not block on multiple re-renders. -// It delegates coalescing + rendering decision to RasterCoalescer. -func (e *client) draw(wPx, hPx int) image.Image { - return e.co.Draw(wPx, hPx) -} - -// renderRasterImage renders the expanded canvas into the GGDrawer backing image, -// then copies only the viewport ROI into a reusable viewport buffer and returns it. -func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image { - if e.world == nil { - return image.NewRGBA(image.Rect(0, 0, 0, 0)) - } - - // Keep the incoming zoom snapshot so we can safely sync corrected zoom back - // to base params only when no newer zoom was written concurrently. - inputZoom := p.CameraZoom - - // Record current raster pixel size (used for event coordinate conversion). - e.metaMu.Lock() - e.lastRasterPxW = viewportW - e.lastRasterPxH = viewportH - e.metaMu.Unlock() - - // Fill viewport/margins derived from draw callback. - p.ViewportWidthPx = viewportW - p.ViewportHeightPx = viewportH - p.MarginXPx = viewportW / 4 - p.MarginYPx = viewportH / 4 - - // Correct zoom for viewport/world constraints, and clamp camera for no-wrap. - correctedZoom := e.world.CorrectCameraZoom(inputZoom, viewportW, viewportH) - p.CameraZoom = correctedZoom - - // Sync corrected zoom to the canonical UI-facing params snapshot. - // Guard prevents stale render snapshots from overwriting a newer zoom value - // that may have been set by another UI event. - e.mu.Lock() - if e.wp.CameraZoom == inputZoom { - e.wp.CameraZoom = correctedZoom - } - e.mu.Unlock() - - // Ensure indexing is up-to-date when viewport size or zoom changes. - zoomFp, err := p.CameraZoomFp() - if err == nil { - if viewportW != e.lastIndexedViewportW || viewportH != e.lastIndexedViewportH || zoomFp != e.lastIndexedZoomFp { - e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom) - e.lastIndexedViewportW = viewportW - e.lastIndexedViewportH = viewportH - e.lastIndexedZoomFp = zoomFp - } - } - - e.world.ClampRenderParamsNoWrap(&p) - - // Ensure backing expanded canvas (gg context) is sized properly. - canvasW := p.CanvasWidthPx() - canvasH := p.CanvasHeightPx() - e.ensureDrawerCanvas(canvasW, canvasH) - - // Render into expanded canvas backing. - _ = e.world.Render(e.drawer, p) // TODO: handle error - - // Save snapshot of params actually used for this render (for HitTest consistency). - e.lastRenderedMu.Lock() - e.lastRenderedParams = p - e.lastRenderedMu.Unlock() - - // Copy viewport ROI into reusable viewport buffer and return it. - e.ensureViewportBuffer(viewportW, viewportH) - - src, ok := e.drawer.DC.Image().(*image.RGBA) - if !ok || src == nil { - return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH)) - } - - copyViewportRGBA(e.viewportImg, src, p.MarginXPx, p.MarginYPx, viewportW, viewportH) - return e.viewportImg -} - -// ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH. -func (e *client) ensureDrawerCanvas(canvasW, canvasH int) { - if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH { - return - } - // world.NewGGContextRGBA should return *gg.Context backed by *image.RGBA (gg.NewContext does). - e.drawer.DC = NewGGContextRGBA(canvasW, canvasH) - e.lastCanvasW = canvasW - e.lastCanvasH = canvasH -} - -func (e *client) ensureViewportBuffer(w, h int) { - if e.viewportImg != nil && e.viewportW == w && e.viewportH == h { - return - } - e.viewportImg = image.NewRGBA(image.Rect(0, 0, w, h)) - e.viewportW = w - e.viewportH = h -} - -func (e *client) getLastRenderedParams() world.RenderParams { - e.lastRenderedMu.RLock() - defer e.lastRenderedMu.RUnlock() - return e.lastRenderedParams -} - -// eventPosToPixel converts event logical coordinates (Fyne units) into pixel coordinates, -// using the last known raster logical size and the last draw callback pixel size. -// -// pixelX = floor(eventX * rasterPixelWidth / rasterLogicalWidth) -func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool) { - e.metaMu.RLock() - logW := e.lastRasterLogicW - logH := e.lastRasterLogicH - pxW := e.lastRasterPxW - pxH := e.lastRasterPxH - e.metaMu.RUnlock() - - if logW <= 0 || logH <= 0 || pxW <= 0 || pxH <= 0 { - return 0, 0, false - } - - x := int(math.Floor(float64(eventX) * float64(pxW) / float64(logW))) - y := int(math.Floor(float64(eventY) * float64(pxH) / float64(logH))) - - // Clamp to viewport bounds. - if x < 0 { - x = 0 - } else if x > pxW { - x = pxW - } - if y < 0 { - y = 0 - } else if y > pxH { - y = pxH - } - return x, y, true -} - -func (e *client) CanvasScale() float32 { - e.metaMu.RLock() - defer e.metaMu.RUnlock() - if e.lastCanvasScale <= 0 { - return 1 - } - return e.lastCanvasScale -} - -func (e *client) ForceFullRedraw() { - if e.world == nil { - return - } - e.world.ForceFullRedrawNext() -} - -func (e *client) onRasterWidgetLayout(fyne.Size) { - e.updateSizes() -} - -// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw. -// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback). -func (e *client) updateSizes() { - canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster) - if canvasObj == nil { - return - } - - sz := e.raster.Size() // logical (Fyne units) - scale := canvasObj.Scale() - - e.metaMu.Lock() - e.lastRasterLogicW = sz.Width - e.lastRasterLogicH = sz.Height - e.lastCanvasScale = scale - e.metaMu.Unlock() - - e.RequestRefresh() -} - -func (e *client) onDragged(ev *fyne.DragEvent) { - e.pan.Dragged(ev) -} - -func (e *client) onDradEnd() { - e.pan.DragEnd() -} - -func (e *client) onScrolled(s *fyne.ScrollEvent) { - if e.world == nil || s == nil { - return - } - - // Use last rendered viewport sizes (pixel) for zoom logic. - e.metaMu.RLock() - vw := e.lastRasterPxW - vh := e.lastRasterPxH - e.metaMu.RUnlock() - if vw <= 0 || vh <= 0 { - return - } - - cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y) - if !ok { - return - } - - e.mu.Lock() - oldZoom := e.wp.CameraZoom - - // Exponential zoom factor; tune later. - const base = 1.005 - delta := float64(s.Scrolled.DY) - newZoom := oldZoom * math.Pow(base, delta) - - newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh) - if newZoom == oldZoom { - e.mu.Unlock() - return - } - - oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom) - if err != nil { - e.mu.Unlock() - return - } - newZoomFp, err := world.CameraZoomToWorldFixed(newZoom) - if err != nil { - e.mu.Unlock() - return - } - - // Pivot zoom for no-wrap behavior. - 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.mu.Unlock() - - // Any zoom change should rebuild index and force full redraw. - e.world.ForceFullRedrawNext() - e.RequestRefresh() -} - -// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA. -// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free. -// It avoids SubImage aliasing issues: dst becomes independent from src backing memory. -func copyViewportRGBA(dst, src *image.RGBA, marginX, marginY, vw, vh int) { - for y := 0; y < vh; y++ { - srcOff := (marginY+y)*src.Stride + marginX*4 - dstOff := y * dst.Stride - n := vw * 4 - copy(dst.Pix[dstOff:dstOff+n], src.Pix[srcOff:srcOff+n]) - } -} - -func NewGGContextRGBA(w, h int) *gg.Context { - return gg.NewContext(w, h) -} diff --git a/client/ui_drag.go b/client/ui_drag.go deleted file mode 100644 index 3b809ec..0000000 --- a/client/ui_drag.go +++ /dev/null @@ -1,110 +0,0 @@ -package client - -import ( - "math" - - "fyne.io/fyne/v2" - - "galaxy/client/world" -) - -/* -Client pan integration for Fyne Draggable: - -- DragEvent.Dragged provides per-event delta in Fyne logical units. -- Client knows canvasScale (pixels per Fyne unit) and converts to pixels. -- We update camera center in world-fixed (CameraXWorldFp/YWorldFp). - -Sign convention (map follows pointer): -- Drag right (dxPx > 0): move world content right => move camera left => CameraXWorldFp -= dxWorldFp -- Drag down (dyPx > 0): move world content down => move camera up => CameraYWorldFp -= dyWorldFp -*/ - -// draggableClient is the minimal interface we need from your client implementation. -// If your Client already has these methods/fields, you can fold the code directly into it. -type draggableClient interface { - // CanvasScale returns pixels per Fyne logical unit. - CanvasScale() float32 - - // UpdateParams applies a mutation and schedules refresh through your coalescer. - UpdateParams(fn func(p *world.RenderParams)) - - // RequestRefresh schedules a refresh with current params (no mutation). - RequestRefresh() - - // ForceFullRedraw forces a full redraw on next Render (used on DragEnd). - ForceFullRedraw() -} - -// PanController holds per-drag transient state. -type PanController struct { - ed draggableClient - - dragging bool - lastFx float32 // last absolute position in Fyne units - lastFy float32 - - // Remainders to keep subpixel fyne->px conversion stable across many events. - remPxX float32 - remPxY float32 -} - -func NewPanController(ed draggableClient) *PanController { - return &PanController{ed: ed} -} - -// Dragged processes one drag event, updates camera center by delta, and schedules redraw. -func (p *PanController) Dragged(ev *fyne.DragEvent) { - if ev == nil { - return - } - - scale := p.ed.CanvasScale() - if scale <= 0 { - return - } - - // DragEvent.Dragged is delta in Fyne logical units (device independent). - // Convert to pixels by multiplying by canvas scale. - dxPxF := ev.Dragged.DX * scale - dyPxF := ev.Dragged.DY * scale - - // accumulate subpixel remainder in pixels - dxPxF += p.remPxX - dyPxF += p.remPxY - - dxPx := int(math.Round(float64(dxPxF))) - dyPx := int(math.Round(float64(dyPxF))) - - p.remPxX = dxPxF - float32(dxPx) - p.remPxY = dyPxF - float32(dyPx) - - if dxPx == 0 && dyPx == 0 { - return - } - - p.ed.UpdateParams(func(rp *world.RenderParams) { - zoomFp, err := rp.CameraZoomFp() - if err != nil || zoomFp <= 0 { - return - } - - dxWorldFp := world.PixelSpanToWorldFixed(dxPx, zoomFp) - dyWorldFp := world.PixelSpanToWorldFixed(dyPx, zoomFp) - - // Map follows pointer - rp.CameraXWorldFp -= dxWorldFp - rp.CameraYWorldFp -= dyWorldFp - }) -} - -// DragEnd ends the drag gesture. We force a full redraw next to eliminate any -// possible artifacts from incremental shifting and to "settle" the final state. -func (p *PanController) DragEnd() { - p.dragging = false - p.remPxX = 0 - p.remPxY = 0 - - p.ed.ForceFullRedraw() - p.ed.RequestRefresh() -} diff --git a/client/ui_test.go b/client/ui_test.go deleted file mode 100644 index 1f02e17..0000000 --- a/client/ui_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package client - -import ( - "image" - "testing" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/test" - - "github.com/stretchr/testify/require" - - "galaxy/client/world" -) - -type fakeClient struct { - scale float32 - p world.RenderParams - - forced bool - updates int - refresh int -} - -func (e *fakeClient) CanvasScale() float32 { return e.scale } - -func (e *fakeClient) UpdateParams(fn func(p *world.RenderParams)) { - fn(&e.p) - e.updates++ -} - -func (e *fakeClient) RequestRefresh() { e.refresh++ } - -func (e *fakeClient) ForceFullRedraw() { e.forced = true } - -func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) { - t.Parallel() - - fe := &fakeClient{ - scale: 1.0, // 1 fyne unit == 1 px for the test - p: world.RenderParams{ - CameraZoom: 1.0, - CameraXWorldFp: 5 * world.SCALE, - CameraYWorldFp: 5 * world.SCALE, - }, - } - - pc := NewPanController(fe) - - // Drag right by +3 px and down by +2 px. - pc.Dragged(&fyne.DragEvent{ - Dragged: fyne.Delta{DX: 3, DY: 2}, - }) - - require.Equal(t, 1, fe.updates) - - // Map follows pointer => camera moves opposite to pointer delta. - require.Equal(t, 5*world.SCALE-3*world.SCALE, fe.p.CameraXWorldFp) - require.Equal(t, 5*world.SCALE-2*world.SCALE, fe.p.CameraYWorldFp) -} - -func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) { - t.Parallel() - - fe := &fakeClient{ - scale: 2.0, // 2 px per fyne unit - p: world.RenderParams{ - CameraZoom: 1.0, - CameraXWorldFp: 0, - CameraYWorldFp: 0, - }, - } - - pc := NewPanController(fe) - - // Dragged.DX=1 fyne unit => 2 px after scaling. - pc.Dragged(&fyne.DragEvent{ - Dragged: fyne.Delta{DX: 1, DY: 0}, - }) - - require.Equal(t, -2*world.SCALE, fe.p.CameraXWorldFp) -} - -func TestPanController_DragEndForcesFullRedrawAndRefresh(t *testing.T) { - t.Parallel() - - fe := &fakeClient{ - scale: 1.0, - p: world.RenderParams{ - CameraZoom: 1.0, - CameraXWorldFp: 0, - CameraYWorldFp: 0, - }, - } - - pc := NewPanController(fe) - - // Simulate a drag start. - pc.Dragged(&fyne.DragEvent{PointEvent: fyne.PointEvent{Position: fyne.Position{X: 1, Y: 1}}}) - - pc.DragEnd() - require.True(t, fe.forced) - require.Equal(t, 1, fe.refresh) -} - -// Optional: demonstrate use of fyne/test package to ensure types are available. -// (Not strictly needed, but keeps fyne dependency "active" in tests.) -func TestFyneTestDriverIsUsable(t *testing.T) { - t.Parallel() - _ = test.NewApp() -} - -type immediateExecutor struct{} - -func (immediateExecutor) Post(fn func()) { - if fn != nil { - fn() - } -} - -type noopRefresher struct{} - -func (noopRefresher) Refresh() {} - -func newZoomSyncTestClient(t *testing.T, worldW, worldH int, cameraZoom float64) *client { - t.Helper() - - w := world.NewWorld(worldW, worldH) - e := &client{ - world: w, - drawer: &world.GGDrawer{}, - wp: &world.RenderParams{ - CameraZoom: cameraZoom, - CameraXWorldFp: w.W / 2, - CameraYWorldFp: w.H / 2, - Options: &world.RenderOptions{DisableWrapScroll: false}, - }, - hits: make([]world.Hit, 5), - } - - e.co = NewRasterCoalescer( - immediateExecutor{}, - noopRefresher{}, - func(wPx, hPx int, _ world.RenderParams) image.Image { - return image.NewRGBA(image.Rect(0, 0, wPx, hPx)) - }, - ) - - return e -} - -func TestRenderRasterImage_SyncsCorrectedZoomToBaseParams(t *testing.T) { - t.Parallel() - - e := newZoomSyncTestClient(t, 10, 10, 1.0) - p := *e.wp - - correctedZoom := e.world.CorrectCameraZoom(p.CameraZoom, 100, 100) - require.NotEqual(t, p.CameraZoom, correctedZoom) - - _ = e.renderRasterImage(100, 100, p) - - require.Equal(t, correctedZoom, e.wp.CameraZoom) -} - -func TestRenderRasterImage_DoesNotOverrideNewerBaseZoom(t *testing.T) { - t.Parallel() - - e := newZoomSyncTestClient(t, 10, 10, 1.0) - p := *e.wp - - // Simulate a newer UI update that happened after this render snapshot was taken. - e.wp.CameraZoom = 3.0 - - _ = e.renderRasterImage(100, 100, p) - - require.Equal(t, 3.0, e.wp.CameraZoom) -} - -func TestPanController_Dragged_AfterRenderZoomCorrection_UsesSyncedZoom(t *testing.T) { - t.Parallel() - - e := newZoomSyncTestClient(t, 10, 10, 1.0) - - // Initial render corrects zoom and syncs it into base params. - _ = e.renderRasterImage(100, 100, *e.wp) - - syncedZoom := e.wp.CameraZoom - require.NotEqual(t, 1.0, syncedZoom) - - zoomFp, err := world.CameraZoomToWorldFixed(syncedZoom) - require.NoError(t, err) - - startX := e.wp.CameraXWorldFp - pan := NewPanController(e) - pan.Dragged(&fyne.DragEvent{ - Dragged: fyne.Delta{DX: 1, DY: 0}, - }) - - expectedShift := world.PixelSpanToWorldFixed(1, zoomFp) - require.Equal(t, startX-expectedShift, e.wp.CameraXWorldFp) -} diff --git a/client/updater/manager.go b/client/updater/manager.go deleted file mode 100644 index ffa890f..0000000 --- a/client/updater/manager.go +++ /dev/null @@ -1,367 +0,0 @@ -// Package updater manages standalone UI client artifacts, version selection, -// and persisted update state shared by the loader and the UI process. -package updater - -import ( - "errors" - "fmt" - "path/filepath" - "runtime" - "slices" - "strings" - - "galaxy/connector" - gerr "galaxy/error" - mc "galaxy/model/client" - "galaxy/storage" - "galaxy/util" -) - -const ( - // ArtifactDir keeps versioned UI executables isolated from user data files. - ArtifactDir = "ui" - // ArtifactPrefix is the file name prefix used for all managed UI artifacts. - ArtifactPrefix = "client-ui" -) - -// LaunchTarget describes the executable artifact selected for the next UI run. -type LaunchTarget struct { - Version string - Path string - Pending bool -} - -// Manager coordinates client update state, artifact downloads, and cleanup. -type Manager struct { - storage storage.Storage - connector connector.Connector - goos string - goarch string - kind string -} - -// Option customizes Manager construction. -type Option func(*Manager) - -// WithPlatform overrides the runtime platform used for version matching. -func WithPlatform(goos, goarch string) Option { - return func(m *Manager) { - if goos != "" { - m.goos = goos - } - if goarch != "" { - m.goarch = goarch - } - } -} - -// WithArtifactKind overrides the artifact kind accepted by the manager. -func WithArtifactKind(kind string) Option { - return func(m *Manager) { - if kind != "" { - m.kind = kind - } - } -} - -// NewManager constructs an update manager for standalone executable artifacts. -func NewManager(s storage.Storage, c connector.Connector, opts ...Option) *Manager { - m := &Manager{ - storage: s, - connector: c, - goos: runtime.GOOS, - goarch: runtime.GOARCH, - kind: connector.ArtifactKindExecutable, - } - for _, opt := range opts { - opt(m) - } - return m -} - -// ArtifactPath returns the deterministic local storage path for the given versioned artifact. -func ArtifactPath(version, goos, goarch, kind string) string { - name := fmt.Sprintf("%s-%s-%s-%s-%s", ArtifactPrefix, version, goos, goarch, kind) - if goos == "windows" { - name += ".exe" - } - return filepath.Join(ArtifactDir, name) -} - -// LatestCompatibleVersion returns the latest supported version for the selected platform and kind. -func LatestCompatibleVersion(versions []connector.VersionInfo, goos, goarch, kind string) (connector.VersionInfo, bool, error) { - platformMatches := make([]connector.VersionInfo, 0, len(versions)) - for _, version := range versions { - if version.OS == goos && version.Arch == goarch { - platformMatches = append(platformMatches, version) - } - } - if len(platformMatches) == 0 { - return connector.VersionInfo{}, false, nil - } - - candidates := make([]connector.VersionInfo, 0, len(platformMatches)) - unsupportedKinds := make(map[string]struct{}) - seenVersion := make(map[string]struct{}) - for _, version := range platformMatches { - if version.Kind != kind { - unsupportedKinds[version.Kind] = struct{}{} - continue - } - if _, ok := seenVersion[version.Version]; ok { - return connector.VersionInfo{}, false, gerr.WrapService( - fmt.Errorf("ambiguous client artifact version %q for %s/%s", version.Version, goos, goarch), - ) - } - seenVersion[version.Version] = struct{}{} - candidates = append(candidates, version) - } - if len(candidates) == 0 { - kinds := make([]string, 0, len(unsupportedKinds)) - for kind := range unsupportedKinds { - kinds = append(kinds, kind) - } - slices.Sort(kinds) - return connector.VersionInfo{}, false, gerr.WrapService( - fmt.Errorf("unsupported client artifact kind(s) for %s/%s: %s", goos, goarch, strings.Join(kinds, ", ")), - ) - } - - type semVersion struct { - info connector.VersionInfo - sem util.SemVer - } - semvers := make([]semVersion, len(candidates)) - for i, candidate := range candidates { - semver, err := util.ParseSemver(candidate.Version) - if err != nil { - return connector.VersionInfo{}, false, gerr.WrapService( - fmt.Errorf("parse client version %q: %w", candidate.Version, err), - ) - } - semvers[i] = semVersion{info: candidate, sem: semver} - } - - slices.SortFunc(semvers, func(a, b semVersion) int { - return util.CompareSemver(a.sem, b.sem) - }) - return semvers[0].info, true, nil -} - -// EnsureLaunchTarget returns the versioned executable that should be launched next. -// On the very first run, when no current or pending version exists yet, it downloads -// the latest compatible artifact and marks it as pending. -func (m *Manager) EnsureLaunchTarget() (LaunchTarget, error) { - state, err := m.ensureState() - if err != nil { - return LaunchTarget{}, err - } - - if state.ClientNextVersion != nil { - return m.launchTargetForVersion(*state.ClientNextVersion, true) - } - if state.ClientCurrentVersion != "" { - return m.launchTargetForVersion(state.ClientCurrentVersion, false) - } - if err := m.CheckAndPrepareLatest(); err != nil { - return LaunchTarget{}, err - } - - state, err = m.ensureState() - if err != nil { - return LaunchTarget{}, err - } - if state.ClientNextVersion == nil { - return LaunchTarget{}, gerr.WrapService(errors.New("latest client version was not prepared for launch")) - } - - return m.launchTargetForVersion(*state.ClientNextVersion, true) -} - -// CheckAndPrepareLatest checks the backend manifest and downloads a newer compatible -// artifact when one exists. -func (m *Manager) CheckAndPrepareLatest() error { - if m.connector == nil { - return gerr.WrapService(errors.New("client updater connector is not configured")) - } - - versions, err := m.connector.CheckVersion() - if err != nil { - return err - } - latest, ok, err := LatestCompatibleVersion(versions, m.goos, m.goarch, m.kind) - if err != nil { - return err - } - if !ok { - return gerr.WrapService( - fmt.Errorf("server did not provide a compatible %s client for %s/%s", m.kind, m.goos, m.goarch), - ) - } - - state, err := m.ensureState() - if err != nil { - return err - } - - latestSemver, err := util.ParseSemver(latest.Version) - if err != nil { - return gerr.WrapService(fmt.Errorf("parse latest client version %q: %w", latest.Version, err)) - } - - if state.ClientCurrentVersion != "" { - currentSemver, err := util.ParseSemver(state.ClientCurrentVersion) - if err != nil { - return gerr.WrapService(fmt.Errorf("parse current client version %q: %w", state.ClientCurrentVersion, err)) - } - if util.CompareSemver(currentSemver, latestSemver) >= 0 { - return nil - } - } - if state.ClientNextVersion != nil { - nextSemver, err := util.ParseSemver(*state.ClientNextVersion) - if err != nil { - return gerr.WrapService(fmt.Errorf("parse pending client version %q: %w", *state.ClientNextVersion, err)) - } - if util.CompareSemver(nextSemver, latestSemver) >= 0 { - return nil - } - } - - if err := m.downloadArtifact(latest); err != nil { - return err - } - - state.ClientNextVersion = &latest.Version - return m.saveState(state) -} - -// MarkLaunchResult records the outcome of a launched artifact and promotes -// pending versions to current only after a successful run. -func (m *Manager) MarkLaunchResult(version string, exitCode int, runErr error) error { - state, err := m.ensureState() - if err != nil { - return err - } - - if state.ClientNextVersion != nil && *state.ClientNextVersion == version { - if runErr == nil && exitCode == 0 { - state.ClientCurrentVersion = version - } - state.ClientNextVersion = nil - if err := m.saveState(state); err != nil { - return err - } - return m.cleanupArtifacts(state) - } - - if runErr == nil && exitCode == 0 { - return m.cleanupArtifacts(state) - } - return nil -} - -func (m *Manager) launchTargetForVersion(version string, pending bool) (LaunchTarget, error) { - path := ArtifactPath(version, m.goos, m.goarch, m.kind) - exists, absPath, err := m.storage.FileExists(path) - if err != nil { - return LaunchTarget{}, err - } - if !exists { - return LaunchTarget{}, gerr.WrapStorage( - fmt.Errorf("client artifact for version %q not found at %q", version, path), - ) - } - return LaunchTarget{ - Version: version, - Path: absPath, - Pending: pending, - }, nil -} - -func (m *Manager) ensureState() (mc.State, error) { - if m.storage == nil { - return mc.State{}, gerr.WrapStorage(errors.New("client updater storage is not configured")) - } - - exists, err := m.storage.StateExists() - if err != nil { - return mc.State{}, err - } - if !exists { - state := mc.State{} - if err := m.storage.SaveState(state); err != nil { - return mc.State{}, err - } - return state, nil - } - return m.storage.LoadState() -} - -func (m *Manager) saveState(state mc.State) error { - return m.storage.SaveState(state) -} - -func (m *Manager) downloadArtifact(version connector.VersionInfo) error { - data, err := m.connector.DownloadVersion(version.URL) - if err != nil { - return err - } - digest := connector.NewSHA256Digest(data) - if !digest.Equal(version.Checksum) { - return gerr.WrapService(fmt.Errorf("downloaded client artifact checksum mismatch for version %s", version.Version)) - } - - path := ArtifactPath(version.Version, version.OS, version.Arch, version.Kind) - exists, _, err := m.storage.FileExists(path) - if err != nil { - return err - } - if exists { - storedData, err := m.storage.ReadFile(path) - if err != nil { - return err - } - if connector.NewSHA256Digest(storedData).Equal(version.Checksum) { - return nil - } - if err := m.storage.DeleteFile(path); err != nil { - return err - } - } - - return m.storage.WriteFile(path, data) -} - -func (m *Manager) cleanupArtifacts(state mc.State) error { - files, err := m.storage.ListFiles() - if err != nil { - return err - } - - retain := make(map[string]struct{}, 2) - if state.ClientCurrentVersion != "" { - retain[ArtifactPath(state.ClientCurrentVersion, m.goos, m.goarch, m.kind)] = struct{}{} - } - if state.ClientNextVersion != nil { - retain[ArtifactPath(*state.ClientNextVersion, m.goos, m.goarch, m.kind)] = struct{}{} - } - - prefix := filepath.ToSlash(ArtifactDir) + "/" - for _, file := range files { - slashed := filepath.ToSlash(file) - if !strings.HasPrefix(slashed, prefix) { - continue - } - if !strings.HasPrefix(filepath.Base(file), ArtifactPrefix+"-") { - continue - } - if _, ok := retain[file]; ok { - continue - } - if err := m.storage.DeleteFile(file); err != nil { - return err - } - } - return nil -} diff --git a/client/updater/manager_test.go b/client/updater/manager_test.go deleted file mode 100644 index 78efbf2..0000000 --- a/client/updater/manager_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package updater - -import ( - "testing" - - "galaxy/connector" - gerr "galaxy/error" - - "github.com/stretchr/testify/require" -) - -func TestArtifactPathWindowsAddsExe(t *testing.T) { - t.Parallel() - - got := ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable) - require.Equal(t, `ui\client-ui-1.2.3-windows-amd64-executable.exe`, got) -} - -func TestLatestCompatibleVersionSelectsPlatformExecutable(t *testing.T) { - t.Parallel() - - versions := []connector.VersionInfo{ - {OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"}, - {OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.0"}, - {OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.3.0"}, - {OS: "windows", Arch: "arm64", Kind: connector.ArtifactKindExecutable, Version: "9.9.9"}, - } - - got, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, "1.3.0", got.Version) -} - -func TestLatestCompatibleVersionRejectsUnsupportedKinds(t *testing.T) { - t.Parallel() - - versions := []connector.VersionInfo{ - {OS: "windows", Arch: "amd64", Kind: "shared-library", Version: "1.0.0"}, - } - - _, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable) - require.False(t, ok) - require.Error(t, err) - require.True(t, gerr.IsService(err)) -} - -func TestLatestCompatibleVersionRejectsAmbiguousVersions(t *testing.T) { - t.Parallel() - - versions := []connector.VersionInfo{ - {OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"}, - {OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"}, - } - - _, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable) - require.False(t, ok) - require.Error(t, err) - require.True(t, gerr.IsService(err)) -} diff --git a/client/widget.go b/client/widget.go deleted file mode 100644 index 095916b..0000000 --- a/client/widget.go +++ /dev/null @@ -1,42 +0,0 @@ -package client - -import ( - "image/color" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/theme" -) - -type rasterWidgetRender struct { - canvas *interactiveRaster - bg *canvas.Raster - onLayout func(fyne.Size) -} - -func (r *rasterWidgetRender) Layout(size fyne.Size) { - r.bg.Resize(size) - r.canvas.raster.Resize(size) - if r.onLayout != nil { - r.onLayout(size) - } -} - -func (r *rasterWidgetRender) MinSize() fyne.Size { - return r.MinSize() -} - -func (r *rasterWidgetRender) Refresh() { - canvas.Refresh(r.canvas) -} - -func (r *rasterWidgetRender) BackgroundColor() color.Color { - return theme.Color(theme.ColorNameBackground) -} - -func (r *rasterWidgetRender) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.bg, r.canvas.raster} -} - -func (r *rasterWidgetRender) Destroy() { -} diff --git a/client/widget/calculator/calculator.go b/client/widget/calculator/calculator.go deleted file mode 100644 index e9922c7..0000000 --- a/client/widget/calculator/calculator.go +++ /dev/null @@ -1,629 +0,0 @@ -package calculator - -import ( - "errors" - "galaxy/calc" - "galaxy/client/widget/numeric" - "galaxy/util" - "slices" - "strconv" - "sync" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/lang" - "fyne.io/fyne/v2/widget" -) - -type CalculatorOpt func(*Calculator) -type ShipClass struct { - Name string - Drive float64 - Armament uint - Weapons float64 - Shields float64 - Cargo float64 -} -type ShipClassFn func(string, float64, uint, float64, float64, float64) - -type Calculator struct { - CanvasObject fyne.CanvasObject - - playerDrivesTech float64 - playerWeaponsTech float64 - playerShieldsTech float64 - playerCargoTech float64 - - shipDriveEntry *numeric.FloatEntry - shipWeaponsEntry *numeric.FloatEntry - shipArmamentEntry *numeric.IntEntry - shipShieldsEntry *numeric.FloatEntry - shipCargoEntry *numeric.FloatEntry - - playerDrivesTechEntry *numeric.FloatEntry - playerWeaponsTechEntry *numeric.FloatEntry - playerShieldsTechEntry *numeric.FloatEntry - playerCargoTechEntry *numeric.FloatEntry - - drivesTechOverride *widget.Check - weaponsTechOverride *widget.Check - shieldsTechOverride *widget.Check - cargoTechOverride *widget.Check - - massEntry *numeric.FloatEntry - speedEntry *numeric.FloatEntry - attackEntry *numeric.FloatEntry - defenseEntry *numeric.FloatEntry - cargoLoadEntry *numeric.FloatEntry - planetMatEntry *numeric.FloatEntry - - massOverride *widget.Check - speedOverride *widget.Check - attackOverride *widget.Check - defenseOverride *widget.Check - cargoLoadMaximize *widget.Check - planetMatOverride *widget.Check - - planetLabel *widget.Label - planetMassProdLabel *widget.Label - planetShipsProdLabel *widget.Label - planetContainer fyne.CanvasObject - planetProdContainer fyne.CanvasObject - - shipSelector *widget.SelectEntry - shipCreateButton *widget.Button - - onCreateHandler ShipClassFn - loader ShipClassFn - knownClasses []ShipClass - - validateMu sync.RWMutex - - l, mat, res float64 - - Valid bool -} - -func WithPlayerDrives(v float64) CalculatorOpt { - return func(c *Calculator) { c.playerDrivesTech = v } -} - -func WithPlayerWeapons(v float64) CalculatorOpt { - return func(c *Calculator) { c.playerWeaponsTech = v } -} - -func WithPlayerShields(v float64) CalculatorOpt { - return func(c *Calculator) { c.playerShieldsTech = v } -} - -func WithPlayerCargo(v float64) CalculatorOpt { - return func(c *Calculator) { c.playerCargoTech = v } -} - -func WithCreateHandler(f ShipClassFn) CalculatorOpt { - return func(c *Calculator) { c.onCreateHandler = f } -} - -func NewCaclulator(opts ...CalculatorOpt) *Calculator { - c := &Calculator{} - - c.shipCreateButton = widget.NewButton(lang.L("ship.action.create"), c.onCreateShipClassButton) - c.shipCreateButton.Disable() - c.loader = c.LoadShipClass - - c.planetMatEntry = numeric.NewFloatEntry(10, c.onPlanetMatChange) - c.planetMatOverride = widget.NewCheck("", c.overridePlanetMat) - c.planetMatOverride.Disable() - c.planetLabel = widget.NewLabel("") - c.planetMassProdLabel = bareLabel("") - c.planetShipsProdLabel = bareLabel("") - c.planetProdContainer = container.NewHBox( - label(lang.L("planet.prod.mass")+":"), - fixedLabel(c.planetMassProdLabel, 80), - label(lang.L("planet.prod.ships")+":"), - fixedLabel(c.planetShipsProdLabel, 80), - ) - c.planetProdContainer.Hide() - - c.planetContainer = container.NewVBox( - widget.NewSeparator(), - container.NewHBox(c.planetLabel), - rowForItem(lang.L("planet.mat")+":", floatEntry(c.planetMatEntry, 100), c.planetMatOverride), - c.planetProdContainer, - ) - c.planetContainer.Hide() - - c.shipSelector = widget.NewSelectEntry(nil) - c.shipSelector.OnChanged = c.onShipSelectorChange - - c.shipDriveEntry = numeric.NewFloatEntry(7, c.onShipDriveChange) - c.shipWeaponsEntry = numeric.NewFloatEntry(7, c.onShipWeaponsChange) - c.shipArmamentEntry = numeric.NewIntEntry(7, c.onShipArmamentChange) - c.shipShieldsEntry = numeric.NewFloatEntry(7, c.onShipShieldsChange) - c.shipCargoEntry = numeric.NewFloatEntry(7, c.onShipCargoChange) - - c.playerDrivesTechEntry = numeric.NewFloatEntry(7, c.onDrivesTechChange) - c.playerWeaponsTechEntry = numeric.NewFloatEntry(7, c.onWeaponsTechChange) - c.playerShieldsTechEntry = numeric.NewFloatEntry(7, c.onShieldsTechChange) - c.playerCargoTechEntry = numeric.NewFloatEntry(7, c.onCargoTechChange) - - c.massEntry = numeric.NewFloatEntry(7, c.onMassChange) - c.speedEntry = numeric.NewFloatEntry(7, c.onSpeedChange) - c.attackEntry = numeric.NewFloatEntry(7, c.onAttackChange) - c.defenseEntry = numeric.NewFloatEntry(7, c.onDefenseChange) - c.cargoLoadEntry = numeric.NewFloatEntry(7, c.onCargoLoadChange) - - c.drivesTechOverride = widget.NewCheck("", c.overrideDrivesTech) - c.drivesTechOverride.Disable() - c.weaponsTechOverride = widget.NewCheck("", c.overrideWeaponsTech) - c.weaponsTechOverride.Disable() - c.shieldsTechOverride = widget.NewCheck("", c.overrideShieldsTech) - c.shieldsTechOverride.Disable() - c.cargoTechOverride = widget.NewCheck("", c.overrideCargoTech) - c.cargoTechOverride.Disable() - - c.massOverride = widget.NewCheck("", c.overrideMass) - c.massOverride.Disable() - c.speedOverride = widget.NewCheck("", c.overrideSpeed) - c.speedOverride.Disable() - c.attackOverride = widget.NewCheck("", c.overrideAttack) - c.attackOverride.Disable() - c.defenseOverride = widget.NewCheck("", c.overrideDefense) - c.defenseOverride.Disable() - c.cargoLoadMaximize = widget.NewCheck(lang.L("label.max"), c.maximizeCargoLoad) - c.cargoLoadMaximize.SetChecked(true) - - createShip := container.NewBorder( - nil, // top - nil, // bottom - nil, // left - c.shipCreateButton, // right - c.shipSelector, // center - ) - - c.CanvasObject = container.NewVBox( - container.NewPadded(createShip), - widget.NewSeparator(), - rowForTech(lang.L("tech.d")+":", - c.shipDriveEntry, floatEntry(c.playerDrivesTechEntry, 80), c.drivesTechOverride), - rowForWeapons(lang.L("tech.w")+":", - c.shipArmamentEntry, c.shipWeaponsEntry, floatEntry(c.playerWeaponsTechEntry, 80), c.weaponsTechOverride), - rowForTech(lang.L("tech.s")+":", - c.shipShieldsEntry, floatEntry(c.playerShieldsTechEntry, 80), c.shieldsTechOverride), - rowForTech(lang.L("tech.c")+":", - c.shipCargoEntry, floatEntry(c.playerCargoTechEntry, 80), c.cargoTechOverride), - widget.NewSeparator(), - rowForItem(lang.L("ship.load")+":", - floatEntry(c.cargoLoadEntry, 80), c.cargoLoadMaximize), - rowForItem(lang.L("ship.mass")+":", - floatEntry(c.massEntry, 80), c.massOverride), - rowForItem(lang.L("ship.speed")+":", - floatEntry(c.speedEntry, 80), c.speedOverride), - rowForItem(lang.L("ship.attack")+":", - floatEntry(c.attackEntry, 80), c.attackOverride), - rowForItem(lang.L("ship.defense")+":", - floatEntry(c.defenseEntry, 80), c.defenseOverride), - c.planetContainer, - ) - - c.Init(opts...) - - return c -} - -func (c *Calculator) Init(opts ...CalculatorOpt) { - for i := range opts { - opts[i](c) - } - - c.playerDrivesTechEntry.SetOrigin(c.playerDrivesTech) - c.playerWeaponsTechEntry.SetOrigin(c.playerWeaponsTech) - c.playerShieldsTechEntry.SetOrigin(c.playerShieldsTech) - c.playerCargoTechEntry.SetOrigin(c.playerCargoTech) - - c.CanvasObject.Show() -} - -func (c *Calculator) Refresh() { - c.validate() - c.CanvasObject.Refresh() -} - -func (c *Calculator) RegisterClasses(shipClass ...ShipClass) { - c.knownClasses = shipClass - names := make([]string, len(c.knownClasses)) - for i := range c.knownClasses { - names[i] = c.knownClasses[i].Name - } - slices.Sort(names) - c.shipSelector = widget.NewSelectEntry(names) - c.shipSelector.OnChanged = c.onShipSelectorChange -} - -func (c *Calculator) onCreateShipClassButton() { - if c.onCreateHandler == nil || !c.Valid { - return - } -} - -func (c *Calculator) validate() { - fyne.Do(func() { - c.validateMu.Lock() - err := c.validateEntries() - c.Valid = err == nil - if err != nil { - } else { - } - c.shipClassNameValidate() - c.validateMu.Unlock() - }) -} - -func (c *Calculator) validateEntries() (err error) { - defer func() { - if err != nil { - c.cargoLoadEntry.Clear() - if !c.massOverride.Checked { - c.massEntry.Clear() - } - if !c.speedOverride.Checked { - c.speedEntry.Clear() - } - if !c.attackOverride.Checked { - c.attackEntry.Clear() - } - if !c.defenseOverride.Checked { - c.defenseEntry.Clear() - } - // c.planetProdContainer.Hide() - } - }() - drive, ok := c.shipDriveEntry.Value() - if !ok { - err = errors.New("Parameter Drive is not valid") - return - } - driveTech, ok := c.playerDrivesTechEntry.Value() - if !ok { - err = errors.New("Drive tech level is not valid") - return - } - armament, ok := c.shipArmamentEntry.Value() - if !ok { - err = errors.New("Parameter Armament is not valid") - return - } - weapons, ok := c.shipWeaponsEntry.Value() - if !ok { - err = errors.New("Parameter Weapons is not valid") - return - } - weaponsTech, ok := c.playerWeaponsTechEntry.Value() - if !ok { - err = errors.New("Weapons tech level is not valid") - return - } - shields, ok := c.shipShieldsEntry.Value() - if !ok { - err = errors.New("Parameter Shields is not valid") - return - } - shieldsTech, ok := c.playerShieldsTechEntry.Value() - if !ok { - err = errors.New("Shields tech level is not valid") - return - } - cargo, ok := c.shipCargoEntry.Value() - if !ok { - err = errors.New("Parameter Cargo is not valid") - return - } - cargoTech, ok := c.playerCargoTechEntry.Value() - if !ok { - err = errors.New("Cargo tech level is not valid") - return - } - - err = calc.ValidateShipTypeValues(drive, armament, weapons, shields, cargo) - if err != nil { - return - } - - var cargoLoad float64 - if c.cargoLoadMaximize.Checked { - cargoLoad = calc.CargoCapacity(cargo, cargoTech) - c.cargoLoadEntry.SetOrigin(cargoLoad) - } else if cargoLoad, ok = c.cargoLoadEntry.Value(); !ok { - err = errors.New("Cargo load value is not valid") - return - } - - emptyMass, ok := calc.EmptyMass(drive, weapons, uint(armament), shields, cargo) - if !ok { - err = errors.New("Unable to calculate empty mass (check armament and weapons)") - return - } - fullMass := calc.FullMass(emptyMass, cargoLoad) - speed := calc.Speed(calc.DriveEffective(drive, driveTech), fullMass) - effectiveAttack := calc.EffectiveAttack(weapons, weaponsTech) - effectiveDefense := calc.EffectiveDefence(shields, shieldsTech, fullMass) - - c.massEntry.SetOrigin(emptyMass) - c.speedEntry.SetOrigin(speed) - c.attackEntry.SetOrigin(effectiveAttack) - c.defenseEntry.SetOrigin(effectiveDefense) - - planetMat, ok := c.planetMatEntry.Value() - if !ok { - // c.planetProdContainer.Hide() - } else { - massProd := calc.PlanetProduceShipMass(c.l, planetMat, c.res) - c.planetMassProdLabel.SetText(strconv.FormatFloat(util.Fixed3(massProd), 'f', -1, 64)) - ships := 0. - if emptyMass > 0 { - ships = massProd / emptyMass - } - c.planetShipsProdLabel.SetText(strconv.FormatFloat(util.Fixed3(ships), 'f', -1, 64)) - c.planetProdContainer.Show() - } - - return -} - -func (c *Calculator) onOriginInputChange(cb *widget.Check, e *numeric.FloatEntry) { - if e == nil { - return - } - if cb != nil { - cb.Checked = e.Overriden() - if !cb.Checked { - cb.Disable() - } else { - cb.Enable() - } - } - c.onFloatEntryChange(e) -} - -func (c *Calculator) onFloatEntryChange(e *numeric.FloatEntry) { - if e == nil { - return - } - e.Validate() - c.validate() -} - -func (c *Calculator) onIntEntryChange(e *numeric.IntEntry) { - if e == nil { - return - } - e.Validate() - c.validate() -} - -func (c *Calculator) overrideChecked(cb *widget.Check, e *numeric.FloatEntry) { - if cb == nil || e == nil { - return - } - if !cb.Checked { - e.Reset() - cb.Disable() - } -} - -func (c *Calculator) onShipDriveChange(string) { - c.onFloatEntryChange(c.shipDriveEntry) -} - -func (c *Calculator) onShipArmamentChange(string) { - defer c.onIntEntryChange(c.shipArmamentEntry) - if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid { - return - } else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid { - return - } else if armament > 0 && weapons == 0 { - c.shipWeaponsEntry.SetOrigin(1.0) - } else if armament == 0 && weapons > 0 { - c.shipWeaponsEntry.SetOrigin(0.0) - } -} - -func (c *Calculator) onShipWeaponsChange(string) { - defer c.onFloatEntryChange(c.shipWeaponsEntry) - if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid { - return - } else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid { - return - } else if weapons > 0 && armament == 0 { - c.shipArmamentEntry.SetOrigin(1) - } else if weapons == 0 && armament > 0 { - c.shipArmamentEntry.SetOrigin(0) - } -} - -func (c *Calculator) onShipShieldsChange(string) { - c.onFloatEntryChange(c.shipShieldsEntry) -} - -func (c *Calculator) onShipCargoChange(string) { - c.onFloatEntryChange(c.shipCargoEntry) -} - -func (c *Calculator) onDrivesTechChange(string) { - c.onOriginInputChange(c.drivesTechOverride, c.playerDrivesTechEntry) -} - -func (c *Calculator) overrideDrivesTech(bool) { - c.overrideChecked(c.drivesTechOverride, c.playerDrivesTechEntry) -} - -func (c *Calculator) onWeaponsTechChange(string) { - c.onOriginInputChange(c.weaponsTechOverride, c.playerWeaponsTechEntry) -} - -func (c *Calculator) overrideWeaponsTech(bool) { - c.overrideChecked(c.weaponsTechOverride, c.playerWeaponsTechEntry) -} - -func (c *Calculator) onShieldsTechChange(string) { - c.onOriginInputChange(c.shieldsTechOverride, c.playerShieldsTechEntry) -} - -func (c *Calculator) overrideShieldsTech(bool) { - c.overrideChecked(c.shieldsTechOverride, c.playerShieldsTechEntry) -} - -func (c *Calculator) onCargoTechChange(string) { - c.onOriginInputChange(c.cargoTechOverride, c.playerCargoTechEntry) -} - -func (c *Calculator) overrideCargoTech(bool) { - c.overrideChecked(c.cargoTechOverride, c.playerCargoTechEntry) -} - -func (c *Calculator) onCargoLoadChange(string) { - c.onFloatEntryChange(c.cargoLoadEntry) -} - -func (c *Calculator) onMassChange(string) { - c.onOriginInputChange(c.massOverride, c.massEntry) -} - -func (c *Calculator) overrideMass(bool) { - c.overrideChecked(c.massOverride, c.massEntry) -} - -func (c *Calculator) onSpeedChange(string) { - c.onOriginInputChange(c.speedOverride, c.speedEntry) -} - -func (c *Calculator) overrideSpeed(bool) { - c.overrideChecked(c.speedOverride, c.speedEntry) -} - -func (c *Calculator) onAttackChange(string) { - c.onOriginInputChange(c.attackOverride, c.attackEntry) -} - -func (c *Calculator) overrideAttack(bool) { - c.overrideChecked(c.attackOverride, c.attackEntry) -} - -func (c *Calculator) onDefenseChange(string) { - c.onOriginInputChange(c.defenseOverride, c.defenseEntry) -} - -func (c *Calculator) overrideDefense(bool) { - c.overrideChecked(c.defenseOverride, c.defenseEntry) -} - -func (c *Calculator) maximizeCargoLoad(bool) { - c.validate() -} - -func (c *Calculator) onPlanetMatChange(string) { - c.onOriginInputChange(c.planetMatOverride, c.planetMatEntry) -} - -func (c *Calculator) overridePlanetMat(bool) { - c.overrideChecked(c.planetMatOverride, c.planetMatEntry) -} - -func (c *Calculator) onShipSelectorChange(v string) { - i, ok := c.shipClassNameValidate() - if i < 0 || !ok || c.loader == nil { - return - } - c.loader( - c.knownClasses[i].Name, - c.knownClasses[i].Drive, - c.knownClasses[i].Armament, - c.knownClasses[i].Weapons, - c.knownClasses[i].Shields, - c.knownClasses[i].Cargo, - ) -} - -func (c *Calculator) shipClassNameValidate() (int, bool) { - var canCreateShip bool - defer func() { - if canCreateShip && c.Valid { - c.shipCreateButton.Enable() - } else { - c.shipCreateButton.Disable() - } - }() - name, canCreateShip := util.ValidateTypeName(c.shipSelector.Text) - if canCreateShip { - c.shipSelector.Text = name - } - i := slices.IndexFunc(c.knownClasses, func(v ShipClass) bool { return v.Name == name }) - canCreateShip = canCreateShip && i < 0 - return i, canCreateShip -} - -func (c *Calculator) LoadShipClass(n string, D float64, A uint, W float64, S float64, C float64) { - c.shipDriveEntry.SetOrigin(D) - c.shipArmamentEntry.SetOrigin(int(A)) - c.shipWeaponsEntry.SetOrigin(W) - c.shipShieldsEntry.SetOrigin(S) - c.shipCargoEntry.SetOrigin(C) -} - -func rowForItem(l string, entry, override fyne.CanvasObject) fyne.CanvasObject { - i := []fyne.CanvasObject{label(l), entry} - if override != nil { - i = append(i, override) - } - return container.NewHBox(i...) -} - -func rowForTech(l string, shipEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject { - return container.NewHBox( - label(l), - floatEntry(shipEntry, 115), - widget.NewLabel("@"), - techEntry, - btn, - ) -} - -func rowForWeapons(l string, armamentEntry, weaponsEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject { - return container.NewHBox( - label(l), - intEntry(armamentEntry, 35), - floatEntry(weaponsEntry, 75), - widget.NewLabel("@"), - techEntry, - btn, - ) -} - -func label(l string) fyne.CanvasObject { - return fixedLabel(bareLabel(l), 110) -} - -func fixedLabel(w *widget.Label, width float32) fyne.CanvasObject { - s := container.NewHScroll(w) - s.SetMinSize(fyne.NewSize(width, 1)) - return s -} - -func bareLabel(l string) *widget.Label { - w := widget.NewLabelWithStyle(l, fyne.TextAlignTrailing, fyne.TextStyle{Monospace: true, Symbol: false}) - w.Selectable = false - w.Truncation = fyne.TextTruncateOff - return w -} - -func intEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject { - s := container.NewHScroll(content) - s.SetMinSize(fyne.NewSize(width, 1)) - return s -} - -func floatEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject { - s := container.NewHScroll(content) - s.SetMinSize(fyne.NewSize(width, 1)) - return s -} diff --git a/client/widget/calculator/planet.go b/client/widget/calculator/planet.go deleted file mode 100644 index 368bd16..0000000 --- a/client/widget/calculator/planet.go +++ /dev/null @@ -1,16 +0,0 @@ -package calculator - -import ( - "fyne.io/fyne/v2/lang" -) - -func (c *Calculator) UnloadPlanet() { - c.planetContainer.Hide() -} - -func (c *Calculator) LoadPlanet(name string, number uint, L, Mat, Res float64) { - c.l, c.mat, c.res = L, Mat, Res - c.planetLabel.SetText(lang.L("planet.title", map[string]any{"Number": number, "Name": name})) - c.planetMatEntry.SetOrigin(Mat) - c.planetContainer.Show() -} diff --git a/client/widget/numeric/numeric.go b/client/widget/numeric/numeric.go deleted file mode 100644 index bd51b5f..0000000 --- a/client/widget/numeric/numeric.go +++ /dev/null @@ -1,217 +0,0 @@ -package numeric - -import ( - "galaxy/client/widget/validator" - "galaxy/util" - "strconv" - "strings" - "unicode/utf8" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/driver/mobile" - "fyne.io/fyne/v2/widget" -) - -type FloatEntry struct { - widget.Entry - origin float64 - MaxValue float64 - maxSize uint - validator fyne.StringValidator - Valid bool -} - -type IntEntry struct { - widget.Entry - origin uint - MaxValue uint - maxSize uint - validator fyne.StringValidator - Valid bool -} - -func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry { - e := &FloatEntry{maxSize: maxSize, validator: validator.FloatEntryValidator} - e.ExtendBaseWidget(e) - e.Entry.Scroll = fyne.ScrollNone - e.Entry.TextStyle = fyne.TextStyle{Monospace: true} - // e.Validator = validator.FloatEntryValidator - // e.AlwaysShowValidationError = true - e.Entry.ActionItem = nil - e.SetOrigin(0) - e.Validate() - e.Entry.OnChanged = onChanged - return e -} - -func NewIntEntry(maxSize uint, onChanged func(string)) *IntEntry { - e := &IntEntry{maxSize: maxSize, validator: validator.IntEntryValidator} - e.ExtendBaseWidget(e) - e.Entry.Scroll = fyne.ScrollNone - e.Entry.TextStyle = fyne.TextStyle{Monospace: true} - // e.Validator = validator.IntEntryValidator - // e.AlwaysShowValidationError = true - e.Entry.ActionItem = nil - e.SetOrigin(0) - e.Validate() - e.Entry.OnChanged = onChanged - return e -} - -func (e *FloatEntry) CreateRenderer() fyne.WidgetRenderer { - r := e.Entry.CreateRenderer() - return r -} - -func (e *FloatEntry) TypedRune(r rune) { - if !((r >= '0' && r <= '9') || r == '.') { - return - } - if !lengthBelowLimit(e.Entry.Text, e.maxSize) && e.Entry.SelectedText() == "" { - return - } - if r == '.' && strings.Contains(e.Entry.Text, ".") { - return - } - e.Entry.TypedRune(r) -} - -func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) { - paste, ok := shortcut.(*fyne.ShortcutPaste) - if !ok { - e.Entry.TypedShortcut(shortcut) - return - } - - content := paste.Clipboard.Content() - if _, err := strconv.ParseFloat(content, 64); err == nil { - e.Entry.TypedShortcut(shortcut) - } -} - -func (e *FloatEntry) Keyboard() mobile.KeyboardType { - return mobile.NumberKeyboard -} - -func (e *FloatEntry) SetOrigin(v float64) { - if v < 0 { - return - } - e.origin = v - e.Reset() -} - -func (e *FloatEntry) Reset() { - e.SetValue(e.origin) -} - -func (e *FloatEntry) Clear() { - onChanged := e.Entry.OnChanged - e.Entry.OnChanged = nil - e.Entry.SetText("") - e.Entry.OnChanged = onChanged -} - -func (e *FloatEntry) SetValue(v float64) { - if v < 0 { - return - } - - e.Entry.SetText(strconv.FormatFloat(util.Fixed3(v), 'f', -1, 64)) -} - -func (e *FloatEntry) Value() (float64, bool) { - if v, err := validator.ParseFloat(e.Entry.Text); err != nil { - return 0, false - } else { - return v, true - } -} - -func (e *FloatEntry) Overriden() bool { - if v, ok := e.Value(); !ok { - return false - } else { - return util.Fixed3(v) != util.Fixed3(e.origin) - } -} - -func (e *FloatEntry) Validate() { - if e.validator == nil { - return - } - err := e.validator(e.Entry.Text) - e.Valid = err == nil -} - -func (e *IntEntry) TypedRune(r rune) { - if r >= '0' && r <= '9' { - if lengthBelowLimit(e.Entry.Text, e.maxSize) || e.Entry.SelectedText() != "" { - e.Entry.TypedRune(r) - } - } -} - -func (e *IntEntry) TypedShortcut(shortcut fyne.Shortcut) { - paste, ok := shortcut.(*fyne.ShortcutPaste) - if !ok { - e.Entry.TypedShortcut(shortcut) - return - } - - content := paste.Clipboard.Content() - if _, err := strconv.ParseInt(content, 10, 64); err == nil { - e.Entry.TypedShortcut(shortcut) - } -} - -func (e *IntEntry) Keyboard() mobile.KeyboardType { - return mobile.NumberKeyboard -} - -func (e *IntEntry) SetOrigin(v int) { - if v < 0 { - return - } - e.origin = uint(v) - e.Reset() -} - -func (e *IntEntry) Reset() { - e.SetValue(int(e.origin)) -} - -func (e *IntEntry) SetValue(v int) { - if v < 0 { - return - } - e.Entry.SetText(strconv.Itoa(v)) -} - -func (e *IntEntry) Value() (int, bool) { - if v, err := validator.ParseInt(e.Entry.Text); err != nil { - return 0, false - } else { - return v, true - } -} - -func (e *IntEntry) Overriden() bool { - if v, ok := e.Value(); !ok { - return false - } else { - return v != int(e.origin) - } -} - -func (e *IntEntry) Validate() { - if e.validator == nil { - return - } - err := e.validator(e.Entry.Text) - e.Valid = err == nil -} - -func lengthBelowLimit(s string, max uint) bool { - return utf8.RuneCountInString(s) < int(max) -} diff --git a/client/widget/validator/validator.go b/client/widget/validator/validator.go deleted file mode 100644 index 92eeaa5..0000000 --- a/client/widget/validator/validator.go +++ /dev/null @@ -1,123 +0,0 @@ -package validator - -import ( - "errors" - "fmt" - "strconv" - - "fyne.io/fyne/v2" -) - -type floatValidator func(float64) error - -var ( - FloatEntryValidator = numericEntryValidator( - nonNegativeValidator, - minOrZeroValueValidator(1.), - ) - IntEntryValidator = numericEntryValidator( - intValidator, - nonNegativeValidator, - minOrZeroValueValidator(1.), - ) -) - -func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator { - if first == nil { - panic("first validator cannot be nil") - } - return func(s string) error { - if err := first(s); err != nil { - return err - } - for i := range rest { - if err := rest[i](s); err != nil { - return err - } - } - return nil - } -} - -func NewMutualValidator(other func() float64, valid func(float64) bool) fyne.StringValidator { - if other == nil { - panic("other value getter cannot be nil") - } - return func(s string) error { - myValue, err := ParseFloat(s) - if err != nil { - return err - } - if !valid(myValue) { - return errors.New("invalid value") - } - if !valid(other()) { - return errors.New("invalid other value") - } - return nil - } -} - -func numericEntryValidator(other ...floatValidator) fyne.StringValidator { - return func(s string) error { - v, err := ParseFloat(s) - if err != nil { - return errors.New("not a float value") - } - for i := range other { - if err := other[i](v); err != nil { - return err - } - } - return nil - } -} - -func nonNegativeValidator(v float64) error { - if v < 0 { - return errors.New("value must be greater of equal to zero") - } - return nil -} - -func intValidator(v float64) error { - if float64(int(v)) != v { - return errors.New("value must be an integer") - } - return nil -} - -func minOrZeroValueValidator(min float64) floatValidator { - return func(f float64) error { - if f > 0 && f < min { - return fmt.Errorf("value must be zero or >= %f", min) - } - return nil - } -} - -func FloatValueValidator(s string) error { - if _, err := ParseFloat(s); err != nil { - return errors.New("not a float value") - } - return nil -} - -func IntValueValidator(s string) error { - if _, err := ParseInt(s); err != nil { - return errors.New("not an integer value") - } - return nil -} - -func ParseFloat(s string) (float64, error) { - return strconv.ParseFloat(s, 64) -} - -func ParseInt(s string) (int, error) { - if v, err := strconv.ParseInt(s, 10, 64); err != nil { - return 0, err - } else { - return int(v), nil - } -} diff --git a/client/world/README.md b/client/world/README.md deleted file mode 100644 index bb2e9df..0000000 --- a/client/world/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# World rendering package - -> **Deprecated.** This package belongs to the deprecated -> `galaxy/client` Fyne client. New code must not import it. The -> active map renderer lives in `ui/frontend/src/map/` (TypeScript -> + PixiJS), with its specification in `ui/docs/renderer.md`. The -> sources here remain for historical context only and are not the -> reference algorithm for the new renderer. - -## Purpose - -`world` is the client-side map model and renderer for a 2D world that normally -behaves like a torus. It owns: - -- primitive storage (`Point`, `Line`, `Circle`) -- world-space indexing for render and hit-test queries -- theme and style resolution -- full-frame and incremental rendering onto an expanded canvas -- no-wrap helpers used by the UI when torus scrolling is disabled - -The package does not own UI widgets, event loops, or camera policy beyond the -helpers exposed for zoom/clamp calculations. - -## Symbol Map - -- World creation and mutation: `NewWorld`, `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `Reindex` -- Viewport/index lifecycle: `IndexOnViewportChange`, `SetCircleRadiusScaleFp` -- Rendering: `Render`, `RenderParams`, `RenderOptions`, `PrimitiveDrawer`, `GGDrawer` -- No-wrap camera helpers: `CorrectCameraZoom`, `ClampCameraNoWrapViewport`, `ClampRenderParamsNoWrap`, `PivotZoomCameraNoWrap` -- Hit testing: `HitTest`, `Hit`, `PrimitiveKind` -- Styling and themes: `Style`, `StyleOverride`, `StyleTable`, `StyleTheme`, `DefaultTheme`, `ThemeLight`, `ThemeDark` - -## Coordinate Model - -- World geometry is stored in fixed-point integers. -- `SCALE == 1000`, so `1.0` world units are represented as `1000`. -- Primitive coordinates, radii, world dimensions, and camera positions use `world-fixed` units. -- Viewport and canvas sizes use integer `canvas px`. -- Rectangles in world space and canvas space are treated as half-open intervals: - `[minX, maxX) x [minY, maxY)`. -- `RenderParams` describes the visible viewport, but rendering happens on the - expanded canvas: - - `canvasWidthPx = viewportWidthPx + 2*marginXPx` - - `canvasHeightPx = viewportHeightPx + 2*marginYPx` -- The camera always points to the center of the visible viewport, not the center - of the expanded canvas. - -## Data Model - -- `World` stores torus dimensions `W` and `H` in fixed-point units. -- `MapItem` is implemented by `Point`, `Line`, and `Circle`. -- `PrimitiveID` is allocated by `World` and may be reused after removal. -- Each primitive carries: - - geometry in fixed-point world coordinates - - `Priority` for deterministic draw order inside a tile - - resolved `StyleID` - - theme binding metadata (`Base`, `Override`, `Class`) - - optional per-primitive hit slop in pixels -- Themes resolve base styles per primitive kind, then optional class overrides, - then optional user `StyleOverride`. -- Explicit `StyleID` bypasses theme-relative recomputation across theme changes. - -## Spatial Index Lifecycle - -- Rendering and hit testing depend on the grid index stored in `World.grid`. -- `IndexOnViewportChange` must be called after viewport size or zoom changes. -- The grid cell size is derived from the current visible world span: - - start from roughly `visibleMin / 8` - - clamp into `[16*SCALE, 512*SCALE]` -- `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `SetCircleRadiusScaleFp`, and - `Reindex` mark the index dirty and rebuild it automatically when the last - viewport/zoom state is known. -- Circle indexing uses the effective radius after `circleRadiusScaleFp` is applied. -- Line indexing uses the torus-shortest representation and indexes its wrapped - bounding boxes rather than exact rasterized coverage. - -## Render Pipeline - -`Render` follows this sequence: - -1. Validate `RenderParams` and resolve background color/theme state. -2. Convert zoom to fixed-point and compute the expanded unwrapped world rect. -3. Split that rect into `WorldTile` segments: - - torus mode uses wrapped tiling - - no-wrap mode intersects against the bounded world once -4. Query the spatial grid per tile and deduplicate candidates per tile by `PrimitiveID`. -5. Build a `RenderPlan` containing: - - tile-to-canvas clip rectangles - - per-tile candidate lists -6. Draw background before primitives. -7. Draw primitives tile-by-tile in deterministic order: - - `Priority` ascending - - primitive kind as stable tie-breaker - - `PrimitiveID` ascending -8. For wrapped rendering: - - points and circles emit only the torus copies that intersect the current tile - - lines are split into torus-shortest canonical segments before projection - -## Incremental Pan Rendering - -- `Render` first tries incremental pan reuse through `ComputePanShiftPx` and - `PlanIncrementalPan`. -- If only camera pan changed and the shift stays inside the configured margins: - - existing pixels are moved with `PrimitiveDrawer.CopyShift` - - newly exposed strips become dirty rects - - dirty rects are cleared, background-redrawn, and clipped primitive redraw is applied -- If geometry changed in a way that breaks reuse, rendering falls back to full redraw. -- Theme changes, circle radius scale changes, and explicit `ForceFullRedrawNext` - reset incremental state. - -## No-Wrap Behavior - -When `RenderOptions.DisableWrapScroll == true`, the world is treated as a bounded -plane instead of a torus. - -- `CorrectCameraZoom` prevents the visible viewport from becoming larger than the world. -- `ClampCameraNoWrapViewport` clamps the camera so the viewport remains inside the world. -- `ClampRenderParamsNoWrap` applies the same rule directly to `RenderParams`. -- `PivotZoomCameraNoWrap` keeps the world point under the cursor stable while zoom changes. - -Margins are ignored by viewport clamp on purpose so panning remains usable even -when the expanded canvas extends beyond the world bounds. - -## Hit Testing - -- `HitTest` expects the grid to be built already. -- Cursor coordinates are passed in viewport pixels relative to the viewport top-left. -- The query path is: - 1. convert cursor position into world-fixed coordinates - 2. clamp or wrap based on no-wrap mode - 3. query a conservative grid search box using default hit slop - 4. run exact per-primitive hit checks -- Point hits use disc distance. -- Circle hits distinguish between filled circles and stroke-only rings. -- Line hits use the same torus-shortest segment decomposition as rendering. -- Final ranking is: - - `Priority` descending - - squared distance ascending - - primitive kind ascending - - `PrimitiveID` ascending - -## UI Integration Checklist - -Typical UI flow: - -1. Create the world with `NewWorld`. -2. Add primitives and optional styles/themes. -3. Before each render, compute the current viewport size in pixels. -4. Call `CorrectCameraZoom` when UI zoom changes. -5. Call `IndexOnViewportChange` when viewport size or zoom changes. -6. If no-wrap mode is enabled, call `ClampRenderParamsNoWrap`. -7. Render into a `PrimitiveDrawer` with `Render`. -8. Reuse the same `RenderParams` snapshot for `HitTest`. - -The `client` package in this repository follows exactly that pattern. - -## Important Invariants and Limits - -- `Render` and `HitTest` require the grid to be initialized; otherwise they return `errGridNotBuilt`. -- The package assumes single-goroutine access to hot render scratch buffers stored in `World`. -- `RenderScheduler` is only a coalescing example. It is not a license to call - `Render` on arbitrary background goroutines in real UI code. -- `PrimitiveDrawer` receives final canvas coordinates only; all torus math stays inside `world`. -- Background anchoring can be viewport-relative or world-relative, but dirty redraws - always use the same anchoring logic as full redraws. diff --git a/client/world/drawer.go b/client/world/drawer.go deleted file mode 100644 index 046d1e3..0000000 --- a/client/world/drawer.go +++ /dev/null @@ -1,642 +0,0 @@ -package world - -import ( - "github.com/fogleman/gg" - "image" - "image/color" - "image/draw" - "reflect" -) - -// PrimitiveDrawer is a low-level drawing backend used by the world renderer. -// -// The renderer is responsible for all torus logic, viewport/margin logic, -// coordinate projection, and primitive duplication. This interface only accepts -// final canvas pixel coordinates and exposes the minimum drawing operations -// needed to build and render paths. -// -// AddPoint, AddLine, and AddCircle append geometry to the current path. -// They do not render by themselves. The caller must finalize the path by -// calling Stroke or Fill. -// -// Save and Restore are intended for temporary local state changes such as -// clipping, colors, line width, or dash settings. After Restore, the outer -// drawing state must be visible again. -type PrimitiveDrawer interface { - // Save stores the current drawing state. - Save() - - // Restore restores the most recently saved drawing state. - Restore() - - // ResetClip clears the current clipping region completely. - ResetClip() - - // ClipRect intersects the current clipping region with the given rectangle - // in canvas pixel coordinates. - ClipRect(x, y, w, h float64) - - // SetStrokeColor sets the color used by Stroke. - SetStrokeColor(c color.Color) - - // SetFillColor sets the color used by Fill. - SetFillColor(c color.Color) - - // SetLineWidth sets the line width used by Stroke. - SetLineWidth(width float64) - - // SetDash sets the dash pattern used by Stroke. - // Passing no values clears the current dash pattern. - SetDash(dashes ...float64) - - // SetDashOffset sets the dash phase used by Stroke. - SetDashOffset(offset float64) - - // AddPoint appends a point marker centered at (x, y) with radius r - // to the current path in canvas pixel coordinates. - AddPoint(x, y, r float64) - - // AddLine appends a line segment to the current path in canvas pixel coordinates. - AddLine(x1, y1, x2, y2 float64) - - // AddCircle appends a circle to the current path in canvas pixel coordinates. - AddCircle(cx, cy, r float64) - - // Stroke renders the current path using the current stroke state. - Stroke() - - // Fill renders the current path using the current fill state. - Fill() - - // CopyShift shifts backing pixels by (dx,dy). Newly exposed areas become transparent/undefined; - // caller is expected to ClearRectTo() the dirty areas before drawing. - CopyShift(dx, dy int) - - // Clear operations must NOT change clip state. - ClearAllTo(bg color.Color) - ClearRectTo(x, y, w, h int, bg color.Color) - - DrawImage(img image.Image, x, y int) - - DrawImageScaled(img image.Image, x, y, w, h int) -} - -// ggClipRect stores one clip rectangle in canvas pixel coordinates. -// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop -// do not restore clip masks the way this package expects. -type ggClipRect struct { - x, y float64 - w, h float64 -} - -// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context. -// -// It intentionally does not perform any world logic. It only forwards already -// projected canvas coordinates to gg while additionally maintaining a clip stack -// compatible with this package's Save/Restore contract. -type GGDrawer struct { - DC *gg.Context - - clips []ggClipRect - clipStack [][]ggClipRect - - // scratch is a reusable buffer for CopyShift to avoid allocations. - scratch *image.RGBA - - bgCache bgTileCache -} - -// Save stores the current gg state and the current logical clip stack. -func (d *GGDrawer) Save() { - d.DC.Push() - - snapshot := append([]ggClipRect(nil), d.clips...) - d.clipStack = append(d.clipStack, snapshot) -} - -// Restore restores the previous gg state and rebuilds the outer clip state. -// -// gg.Context.Pop restores most state from the stack, but its clip mask handling -// does not match this package's expected Save/Restore semantics. To preserve the -// contract, GGDrawer explicitly resets the clip and replays the previously saved -// clip rectangles after Pop. -func (d *GGDrawer) Restore() { - if len(d.clipStack) == 0 { - panic("GGDrawer: Restore without matching Save") - } - - snapshot := d.clipStack[len(d.clipStack)-1] - d.clipStack = d.clipStack[:len(d.clipStack)-1] - - d.DC.Pop() - - d.clips = append([]ggClipRect(nil), snapshot...) - d.DC.ResetClip() - for _, clip := range d.clips { - d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h) - d.DC.Clip() - } -} - -// ResetClip clears the current clipping region and the logical clip stack -// for the active state frame. -func (d *GGDrawer) ResetClip() { - d.DC.ResetClip() - d.clips = nil -} - -// ClipRect intersects the current clipping region with the given rectangle -// and records it so the clip can be reconstructed after Restore. -func (d *GGDrawer) ClipRect(x, y, w, h float64) { - d.DC.DrawRectangle(x, y, w, h) - d.DC.Clip() - - d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h}) -} - -// SetStrokeColor sets the stroke color by installing a solid stroke pattern. -func (d *GGDrawer) SetStrokeColor(c color.Color) { - d.DC.SetStrokeStyle(gg.NewSolidPattern(c)) -} - -// SetFillColor sets the fill color by installing a solid fill pattern. -func (d *GGDrawer) SetFillColor(c color.Color) { - d.DC.SetFillStyle(gg.NewSolidPattern(c)) -} - -// SetLineWidth sets the line width used for stroking. -func (d *GGDrawer) SetLineWidth(width float64) { - d.DC.SetLineWidth(width) -} - -// SetDash sets the dash pattern used for stroking. -func (d *GGDrawer) SetDash(dashes ...float64) { - d.DC.SetDash(dashes...) -} - -// SetDashOffset sets the dash phase used for stroking. -func (d *GGDrawer) SetDashOffset(offset float64) { - d.DC.SetDashOffset(offset) -} - -// AddPoint appends a point marker to the current path. -func (d *GGDrawer) AddPoint(x, y, r float64) { - d.DC.DrawPoint(x, y, r) -} - -// AddLine appends a line segment to the current path. -func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) { - d.DC.DrawLine(x1, y1, x2, y2) -} - -// AddCircle appends a circle to the current path. -func (d *GGDrawer) AddCircle(cx, cy, r float64) { - d.DC.DrawCircle(cx, cy, r) -} - -// Stroke renders the current path using the current stroke state. -func (d *GGDrawer) Stroke() { - d.DC.Stroke() -} - -// Fill renders the current path using the current fill state. -func (d *GGDrawer) Fill() { - d.DC.Fill() -} - -// CopyShift shifts the backing RGBA image by (dx, dy) pixels. -// It clears newly exposed areas to transparent. -func (d *GGDrawer) CopyShift(dx, dy int) { - if dx == 0 && dy == 0 { - return - } - - img, ok := d.DC.Image().(*image.RGBA) - if !ok || img == nil { - panic("GGDrawer.CopyShift: backing image is not *image.RGBA") - } - - b := img.Bounds() - w := b.Dx() - h := b.Dy() - if w <= 0 || h <= 0 { - return - } - - adx := abs(dx) - ady := abs(dy) - if adx >= w || ady >= h { - // Everything shifts out of bounds => just clear. - for i := range img.Pix { - img.Pix[i] = 0 - } - return - } - - // Prepare scratch with the same bounds. - if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h { - d.scratch = image.NewRGBA(b) - } else { - // Clear scratch to transparent. - for i := range d.scratch.Pix { - d.scratch.Pix[i] = 0 - } - } - - // Compute source/destination rectangles. - dstX0 := 0 - dstY0 := 0 - srcX0 := 0 - srcY0 := 0 - if dx > 0 { - dstX0 = dx - } else { - srcX0 = -dx - } - if dy > 0 { - dstY0 = dy - } else { - srcY0 = -dy - } - - copyW := w - max(dstX0, srcX0) - copyH := h - max(dstY0, srcY0) - if copyW <= 0 || copyH <= 0 { - for i := range img.Pix { - img.Pix[i] = 0 - } - return - } - - // Copy row-by-row (RGBA, 4 bytes per pixel). - for row := 0; row < copyH; row++ { - srcY := srcY0 + row - dstY := dstY0 + row - - srcOff := srcY*img.Stride + srcX0*4 - dstOff := dstY*d.scratch.Stride + dstX0*4 - n := copyW * 4 - - copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n]) - } - - // Swap buffers by copying scratch into img. - // (We keep img pointer stable for gg.Context.) - copy(img.Pix, d.scratch.Pix) -} - -func (d *GGDrawer) ClearAllTo(bg color.Color) { - img, ok := d.DC.Image().(*image.RGBA) - if !ok || img == nil { - panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA") - } - - R, G, B, A := rgba8(bg) - - // Prepare one full scanline once. - w := img.Bounds().Dx() - if w <= 0 { - return - } - line := make([]byte, w*4) - for i := 0; i < len(line); i += 4 { - line[i+0] = R - line[i+1] = G - line[i+2] = B - line[i+3] = A - } - - // Copy scanline into each row (fast memmove). - h := img.Bounds().Dy() - for y := 0; y < h; y++ { - off := y * img.Stride - copy(img.Pix[off:off+w*4], line) - } -} - -func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) { - if w <= 0 || h <= 0 { - return - } - - img, ok := d.DC.Image().(*image.RGBA) - if !ok || img == nil { - panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA") - } - - b := img.Bounds() - x0 := max(x, b.Min.X) - y0 := max(y, b.Min.Y) - x1 := min(x+w, b.Max.X) - y1 := min(y+h, b.Max.Y) - if x0 >= x1 || y0 >= y1 { - return - } - - R, G, B, A := rgba8(bg) - - rowPx := x1 - x0 - rowBytes := rowPx * 4 - - // Build one row once for this rect width. - line := make([]byte, rowBytes) - for i := 0; i < rowBytes; i += 4 { - line[i+0] = R - line[i+1] = G - line[i+2] = B - line[i+3] = A - } - - for yy := y0; yy < y1; yy++ { - off := yy*img.Stride + x0*4 - copy(img.Pix[off:off+rowBytes], line) - } -} - -// rgba8 converts any color.Color into 8-bit RGBA components. -func rgba8(c color.Color) (R, G, B, A byte) { - r, g, b, a := c.RGBA() - return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8) -} - -func (g *GGDrawer) DrawImage(img image.Image, x, y int) { - g.DC.DrawImage(img, x, y) -} - -func (g *GGDrawer) DrawImageScaled(img image.Image, x, y, w, h int) { - if w <= 0 || h <= 0 { - return - } - b := img.Bounds() - srcW := b.Dx() - srcH := b.Dy() - if srcW <= 0 || srcH <= 0 { - return - } - - g.DC.Push() - // Translate to destination top-left. - g.DC.Translate(float64(x), float64(y)) - // Scale so that the source bounds map to (w,h). - g.DC.Scale(float64(w)/float64(srcW), float64(h)/float64(srcH)) - // Draw at origin in the scaled coordinate system. - g.DC.DrawImage(img, 0, 0) - g.DC.Pop() -} - -// bgTileCacheKey identifies one scaled background-tile variant cached by GGDrawer. -type bgTileCacheKey struct { - imgPtr uintptr - scaleMode BackgroundScaleMode - canvasW int - canvasH int - srcW int - srcH int -} - -// bgTileCache stores the most recently used scaled background tile. -type bgTileCache struct { - key bgTileCacheKey - valid bool - scaledTile *image.RGBA - tileW int - tileH int -} - -// drawBackgroundFast renders the background directly into the RGBA backing -// image, bypassing gg path construction when the drawer supports it. -func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool { - th := w.Theme() - bgImg := th.BackgroundImage() - if bgImg == nil { - return false - } - - dst, ok := g.DC.Image().(*image.RGBA) - if !ok || dst == nil { - return false - } - - canvasW := params.CanvasWidthPx() - canvasH := params.CanvasHeightPx() - - // Clamp rect to canvas. - if rect.W <= 0 || rect.H <= 0 { - return true - } - if rect.X < 0 { - rect.W += rect.X - rect.X = 0 - } - if rect.Y < 0 { - rect.H += rect.Y - rect.Y = 0 - } - if rect.X+rect.W > canvasW { - rect.W = canvasW - rect.X - } - if rect.Y+rect.H > canvasH { - rect.H = canvasH - rect.Y - } - if rect.W <= 0 || rect.H <= 0 { - return true - } - - imgB := bgImg.Bounds() - srcW := imgB.Dx() - srcH := imgB.Dy() - if srcW <= 0 || srcH <= 0 { - return true - } - - tileMode := th.BackgroundTileMode() - anchor := th.BackgroundAnchorMode() - scaleMode := th.BackgroundScaleMode() - - // Compute scaled tile size in pixels (scale depends on canvas size). - tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode) - if tileW <= 0 || tileH <= 0 { - return true - } - - // Prepare the tile image (possibly scaled) from cache. - tile := bgImg - if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH { - rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH) - if rgbaTile == nil { - // Fallback to slow path if we cannot scale (non-RGBA weirdness). - return false - } - tile = rgbaTile - } - - offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor) - - switch tileMode { - case BackgroundTileNone: - // Draw single image centered in full canvas, then clipped by rect. - x := (canvasW-tileW)/2 + offX - y := (canvasH-tileH)/2 + offY - w.drawOneTileRGBA(dst, tile, rect, x, y) - - case BackgroundTileRepeat: - originX := offX - originY := offY - - startX := floorDiv(rect.X-originX, tileW)*tileW + originX - startY := floorDiv(rect.Y-originY, tileH)*tileH + originY - - for yy := startY; yy < rect.Y+rect.H; yy += tileH { - for xx := startX; xx < rect.X+rect.W; xx += tileW { - w.drawOneTileRGBA(dst, tile, rect, xx, yy) - } - } - - default: - // Treat unknown as none. - x := (canvasW-tileW)/2 + offX - y := (canvasH-tileH)/2 + offY - w.drawOneTileRGBA(dst, tile, rect, x, y) - } - - return true -} - -// getOrBuildScaledTile returns the cached scaled tile image for the current -// background configuration, rebuilding it when the cache key changes. -func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA { - // Identify image pointer (themes typically provide *image.RGBA). - ptr := imagePointer(img) - - key := bgTileCacheKey{ - imgPtr: ptr, - scaleMode: mode, - canvasW: canvasW, - canvasH: canvasH, - srcW: srcW, - srcH: srcH, - } - if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil && - g.bgCache.tileW == dstW && g.bgCache.tileH == dstH { - return g.bgCache.scaledTile - } - - // Scale only from *image.RGBA fast; otherwise, try a generic slow path. - var scaled *image.RGBA - if srcRGBA, ok := img.(*image.RGBA); ok { - scaled = scaleNearestRGBA(srcRGBA, dstW, dstH) - } else { - scaled = scaleNearestGeneric(img, dstW, dstH) - } - - g.bgCache.key = key - g.bgCache.valid = true - g.bgCache.scaledTile = scaled - g.bgCache.tileW = dstW - g.bgCache.tileH = dstH - - return scaled -} - -// imagePointer returns a stable pointer identity for pointer-backed images. -// Non-pointer image values return 0, which disables cache reuse but remains correct. -func imagePointer(img image.Image) uintptr { - // Works well when img is a pointer type (e.g. *image.RGBA). - // If not pointer, Pointer() returns 0; cache will be less effective but still correct. - v := reflect.ValueOf(img) - if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer { - return v.Pointer() - } - return 0 -} - -// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling. -// This is intended for background textures; performance > quality. -func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA { - if dstW <= 0 || dstH <= 0 { - return nil - } - sb := src.Bounds() - sw := sb.Dx() - sh := sb.Dy() - if sw <= 0 || sh <= 0 { - return nil - } - - dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) - - for y := 0; y < dstH; y++ { - sy := (y * sh) / dstH - srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4 - dstOff := y * dst.Stride - for x := 0; x < dstW; x++ { - sx := (x * sw) / dstW - si := srcOff + sx*4 - di := dstOff + x*4 - dst.Pix[di+0] = src.Pix[si+0] - dst.Pix[di+1] = src.Pix[si+1] - dst.Pix[di+2] = src.Pix[si+2] - dst.Pix[di+3] = src.Pix[si+3] - } - } - - return dst -} - -// scaleNearestGeneric scales an arbitrary image.Image with nearest-neighbor sampling. -func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA { - if dstW <= 0 || dstH <= 0 { - return nil - } - sb := src.Bounds() - sw := sb.Dx() - sh := sb.Dy() - if sw <= 0 || sh <= 0 { - return nil - } - - dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) - for y := 0; y < dstH; y++ { - sy := sb.Min.Y + (y*sh)/dstH - for x := 0; x < dstW; x++ { - sx := sb.Min.X + (x*sw)/dstW - dst.Set(x, y, src.At(sx, sy)) - } - } - return dst -} - -// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect. -// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color. -func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) { - tileB := tile.Bounds() - tw := tileB.Dx() - th := tileB.Dy() - if tw <= 0 || th <= 0 { - return - } - - // Intersection of tile rect and target rect. - tx0 := x - ty0 := y - tx1 := x + tw - ty1 := y + th - - rx0 := rect.X - ry0 := rect.Y - rx1 := rect.X + rect.W - ry1 := rect.Y + rect.H - - ix0 := max(tx0, rx0) - iy0 := max(ty0, ry0) - ix1 := min(tx1, rx1) - iy1 := min(ty1, ry1) - if ix0 >= ix1 || iy0 >= iy1 { - return - } - - dstR := image.Rect(ix0, iy0, ix1, iy1) - srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)} - - draw.Draw(dst, dstR, tile, srcPt, draw.Over) -} diff --git a/client/world/drawer_test.go b/client/world/drawer_test.go deleted file mode 100644 index a69dd32..0000000 --- a/client/world/drawer_test.go +++ /dev/null @@ -1,661 +0,0 @@ -package world - -import ( - "fmt" - "github.com/fogleman/gg" - "github.com/stretchr/testify/require" - "image" - "image/color" - "sync" - "testing" -) - -func hasAnyNonTransparentPixel(img image.Image) bool { - b := img.Bounds() - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - _, _, _, a := img.At(x, y).RGBA() - if a != 0 { - return true - } - } - } - return false -} - -func pixelHasAlpha(img image.Image, x, y int) bool { - _, _, _, a := img.At(x, y).RGBA() - return a != 0 -} - -// TestGGDrawerStrokeSequenceProducesPixels verifies gG Drawer Stroke Sequence Produces Pixels. -func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.SetStrokeColor(color.RGBA{R: 255, A: 255}) - drawer.SetLineWidth(2) - drawer.SetDash(4, 2) - drawer.SetDashOffset(1) - drawer.AddLine(4, 16, 28, 16) - drawer.Stroke() - - require.True(t, hasAnyNonTransparentPixel(dc.Image())) -} - -// TestGGDrawerFillSequenceProducesPixels verifies gG Drawer Fill Sequence Produces Pixels. -func TestGGDrawerFillSequenceProducesPixels(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.SetFillColor(color.RGBA{G: 255, A: 255}) - drawer.AddCircle(16, 16, 6) - drawer.Fill() - - require.True(t, pixelHasAlpha(dc.Image(), 16, 16)) -} - -// TestGGDrawerPointSequenceProducesPixels verifies gG Drawer Point Sequence Produces Pixels. -func TestGGDrawerPointSequenceProducesPixels(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.SetFillColor(color.RGBA{B: 255, A: 255}) - drawer.AddPoint(16, 16, 3) - drawer.Fill() - - require.True(t, pixelHasAlpha(dc.Image(), 16, 16)) -} - -// TestGGDrawerClipRectLimitsDrawing verifies gG Drawer Clip Rect Limits Drawing. -func TestGGDrawerClipRectLimitsDrawing(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.Save() - drawer.ClipRect(0, 0, 10, 32) - drawer.SetFillColor(color.RGBA{B: 255, A: 255}) - drawer.AddCircle(15, 16, 10) - drawer.Fill() - drawer.Restore() - - img := dc.Image() - - require.True(t, pixelHasAlpha(img, 5, 16)) - require.False(t, pixelHasAlpha(img, 15, 16)) -} - -// TestGGDrawerResetClipClearsClip verifies gG Drawer Reset Clip Clears Clip. -func TestGGDrawerResetClipClearsClip(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.ClipRect(0, 0, 10, 32) - drawer.ResetClip() - drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255}) - drawer.AddCircle(15, 16, 10) - drawer.Fill() - - require.True(t, pixelHasAlpha(dc.Image(), 15, 16)) -} - -// TestGGDrawerClearRectTo_FillsBackground verifies gG Drawer Clear Rect To Fills Background. -func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(10, 10) - dr := &GGDrawer{DC: dc} - - // Draw something to ensure we overwrite non-background. - dr.SetFillColor(color.RGBA{R: 255, A: 255}) - dr.AddCircle(5, 5, 5) - dr.Fill() - - bg := color.RGBA{A: 255} // black - dr.ClearRectTo(1, 1, 2, 2, bg) - - img := dc.Image() - r, g, b, a := img.At(1, 1).RGBA() - - require.Equal(t, uint32(0), r) - require.Equal(t, uint32(0), g) - require.Equal(t, uint32(0), b) - require.Equal(t, uint32(0xffff), a) - - // Pixel outside cleared rect should still have non-zero alpha. - _, _, _, a2 := img.At(5, 5).RGBA() - require.NotEqual(t, uint32(0), a2) -} - -// TestGGDrawerSaveRestoreRestoresClipState verifies gG Drawer Save Restore Restores Clip State. -func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.Save() - drawer.ClipRect(0, 0, 10, 32) - drawer.Restore() - - drawer.SetFillColor(color.RGBA{R: 255, A: 255}) - drawer.AddCircle(15, 16, 10) - drawer.Fill() - - require.True(t, pixelHasAlpha(dc.Image(), 15, 16)) -} - -// TestGGDrawerNestedSaveRestoreRestoresOuterClip verifies gG Drawer Nested Save Restore Restores Outer Clip. -func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(32, 32) - drawer := &GGDrawer{DC: dc} - - drawer.ClipRect(0, 0, 20, 32) - - drawer.Save() - drawer.ClipRect(0, 0, 10, 32) - drawer.Restore() - - drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255}) - drawer.AddCircle(15, 16, 10) - drawer.Fill() - - img := dc.Image() - - require.True(t, pixelHasAlpha(img, 15, 16)) - require.False(t, pixelHasAlpha(img, 25, 16)) -} - -// TestFakePrimitiveDrawerRecordsCommandsAndState verifies fake Primitive Drawer Records Commands And State. -func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) { - t.Parallel() - - d := &fakePrimitiveDrawer{} - - d.Save() - d.ClipRect(1, 2, 30, 40) - d.SetStrokeColor(color.RGBA{R: 10, G: 20, B: 30, A: 255}) - d.SetFillColor(color.RGBA{R: 40, G: 50, B: 60, A: 255}) - d.SetLineWidth(3) - d.SetDash(5, 6) - d.SetDashOffset(7) - d.AddLine(10, 11, 12, 13) - d.Stroke() - d.Restore() - - requireDrawerCommandNames(t, d, - "Save", - "ClipRect", - "SetStrokeColor", - "SetFillColor", - "SetLineWidth", - "SetDash", - "SetDashOffset", - "AddLine", - "Stroke", - "Restore", - ) - - cmd := requireDrawerSingleCommand(t, d, "AddLine") - requireCommandArgs(t, cmd, 10, 11, 12, 13) - requireCommandLineWidth(t, cmd, 3) - requireCommandDashes(t, cmd, 5, 6) - requireCommandDashOffset(t, cmd, 7) - requireCommandClipRects(t, cmd, fakeClipRect{X: 1, Y: 2, W: 30, H: 40}) - require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, cmd.StrokeColor) - require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor) -} - -// TestFakePrimitiveDrawerRestoreWithoutSavePanics verifies fake Primitive Drawer Restore Without Save Panics. -func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) { - t.Parallel() - - d := &fakePrimitiveDrawer{} - - require.Panics(t, func() { - d.Restore() - }) -} - -// TestFakePrimitiveDrawerSaveRestoreRestoresState verifies fake Primitive Drawer Save Restore Restores State. -func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) { - t.Parallel() - - d := &fakePrimitiveDrawer{} - - d.SetLineWidth(1) - d.Save() - d.SetLineWidth(9) - d.ClipRect(1, 2, 3, 4) - d.Restore() - - state := d.CurrentState() - - require.Equal(t, 1.0, state.LineWidth) - require.Empty(t, state.Clips) - require.Equal(t, 0, d.SaveDepth()) -} - -// TestFakePrimitiveDrawerResetClipClearsOnlyClipState verifies fake Primitive Drawer Reset Clip Clears Only Clip State. -func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) { - t.Parallel() - - d := &fakePrimitiveDrawer{} - - d.SetLineWidth(4) - d.ClipRect(1, 2, 3, 4) - d.ResetClip() - - state := d.CurrentState() - - require.Equal(t, 4.0, state.LineWidth) - require.Empty(t, state.Clips) -} - -// TestGGDrawerCopyShift_ShiftsPixels verifies gG Drawer Copy Shift Shifts Pixels. -func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(10, 10) - drawer := &GGDrawer{DC: dc} - - // Draw a single filled point at (1,1). - drawer.SetFillColor(color.RGBA{R: 255, A: 255}) - drawer.AddPoint(1, 1, 1) - drawer.Fill() - - // Shift image right by 2 and down by 3. - drawer.CopyShift(2, 3) - - img := dc.Image() - - // The old pixel near (1,1) should now be present near (3,4). - // We check alpha only to avoid depending on exact blending. - _, _, _, a := img.At(3, 4).RGBA() - require.NotEqual(t, uint32(0), a) - - // A pixel in the newly exposed top-left area should be transparent. - _, _, _, a2 := img.At(0, 0).RGBA() - require.Equal(t, uint32(0), a2) -} - -// TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState verifies gG Drawer Clear Rect To Does Not Affect Stroke State. -func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) { - t.Parallel() - - dc := gg.NewContext(40, 20) - d := &GGDrawer{DC: dc} - - // Fill background to white. - d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255}) - - // Configure stroke to red and draw first line. - d.SetStrokeColor(color.RGBA{R: 255, A: 255}) - d.SetLineWidth(2) - d.AddLine(2, 5, 38, 5) - d.Stroke() - - // Clear a rect in the middle with gray (must not affect stroke state). - d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255}) - - // Draw second line WITHOUT reapplying stroke style; it must still be red. - d.AddLine(2, 15, 38, 15) - d.Stroke() - - img := dc.Image() - - // Sample a pixel from the second line (y ~15). We expect red channel dominates. - r, g, b, a := img.At(20, 15).RGBA() - require.Greater(t, a, uint32(0), "pixel must not be fully transparent") - require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo") - require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo") -} - -// fakeClipRect describes one clip rectangle in canvas pixel coordinates. -type fakeClipRect struct { - X, Y float64 - W, H float64 -} - -// fakeDrawerState stores the active fake drawing state. -// The state is copied on Save and restored on Restore. -type fakeDrawerState struct { - StrokeColor color.RGBA - FillColor color.RGBA - LineWidth float64 - Dashes []float64 - DashOffset float64 - Clips []fakeClipRect -} - -// clone returns a deep copy of the state. -func (s fakeDrawerState) clone() fakeDrawerState { - out := s - out.Dashes = append([]float64(nil), s.Dashes...) - out.Clips = append([]fakeClipRect(nil), s.Clips...) - return out -} - -// fakeDrawerCommand is one recorded drawer call together with a snapshot -// of the active fake drawing state at the moment of the call. -type fakeDrawerCommand struct { - Name string - Args []float64 - StrokeColor color.RGBA - FillColor color.RGBA - LineWidth float64 - Dashes []float64 - DashOffset float64 - Clips []fakeClipRect -} - -// String returns a compact debug representation useful in assertion failures. -func (c fakeDrawerCommand) String() string { - return fmt.Sprintf( - "%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v", - c.Name, - c.Args, - c.StrokeColor, - c.FillColor, - c.LineWidth, - c.Dashes, - c.DashOffset, - c.Clips, - ) -} - -// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double. -// It records all calls and emulates stateful behavior, including nested -// Save/Restore and clip reset semantics. -type fakePrimitiveDrawer struct { - commands []fakeDrawerCommand - state fakeDrawerState - stack []fakeDrawerState - mu sync.Mutex -} - -// Ensure fakePrimitiveDrawer implements PrimitiveDrawer. -var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil) - -// rgbaColor converts any color.Color into a comparable RGBA value. -func rgbaColor(c color.Color) color.RGBA { - if c == nil { - return color.RGBA{} - } - return color.RGBAModel.Convert(c).(color.RGBA) -} - -// snapshotCommand records one command together with the current state snapshot. -func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) { - cmd := fakeDrawerCommand{ - Name: name, - Args: append([]float64(nil), args...), - StrokeColor: d.state.StrokeColor, - FillColor: d.state.FillColor, - LineWidth: d.state.LineWidth, - Dashes: append([]float64(nil), d.state.Dashes...), - DashOffset: d.state.DashOffset, - Clips: append([]fakeClipRect(nil), d.state.Clips...), - } - d.commands = append(d.commands, cmd) -} - -// Save stores the current fake state. -func (d *fakePrimitiveDrawer) Save() { - d.stack = append(d.stack, d.state.clone()) - d.snapshotCommand("Save") -} - -// Restore restores the most recently saved fake state. -func (d *fakePrimitiveDrawer) Restore() { - if len(d.stack) == 0 { - panic("fakePrimitiveDrawer: Restore without matching Save") - } - - d.state = d.stack[len(d.stack)-1] - d.stack = d.stack[:len(d.stack)-1] - d.snapshotCommand("Restore") -} - -// ResetClip clears the current fake clip stack. -func (d *fakePrimitiveDrawer) ResetClip() { - d.state.Clips = nil - d.snapshotCommand("ResetClip") -} - -// ClipRect appends one clip rectangle to the current fake state. -func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) { - d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h}) - d.snapshotCommand("ClipRect", x, y, w, h) -} - -// SetStrokeColor sets the current fake stroke color. -func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) { - d.state.StrokeColor = rgbaColor(c) - d.snapshotCommand("SetStrokeColor") -} - -// SetFillColor sets the current fake fill color. -func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) { - d.state.FillColor = rgbaColor(c) - d.snapshotCommand("SetFillColor") -} - -// SetLineWidth sets the current fake line width. -func (d *fakePrimitiveDrawer) SetLineWidth(width float64) { - d.state.LineWidth = width - d.snapshotCommand("SetLineWidth", width) -} - -// SetDash sets the current fake dash pattern. -func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) { - d.state.Dashes = append([]float64(nil), dashes...) - d.snapshotCommand("SetDash", dashes...) -} - -// SetDashOffset sets the current fake dash offset. -func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) { - d.state.DashOffset = offset - d.snapshotCommand("SetDashOffset", offset) -} - -// AddPoint records a point path append command. -func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) { - d.snapshotCommand("AddPoint", x, y, r) -} - -// AddLine records a line path append command. -func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) { - d.snapshotCommand("AddLine", x1, y1, x2, y2) -} - -// AddCircle records a circle path append command. -func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) { - d.snapshotCommand("AddCircle", cx, cy, r) -} - -// Stroke records a stroke finalization command. -func (d *fakePrimitiveDrawer) Stroke() { - d.snapshotCommand("Stroke") -} - -// Fill records a fill finalization command. -func (d *fakePrimitiveDrawer) Fill() { - d.snapshotCommand("Fill") -} - -// Commands returns a defensive copy of the recorded command log. -func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand { - out := make([]fakeDrawerCommand, len(d.commands)) - copy(out, d.commands) - return out -} - -// CommandNames returns only command names in call order. -func (d *fakePrimitiveDrawer) CommandNames() []string { - out := make([]string, 0, len(d.commands)) - for _, cmd := range d.commands { - out = append(out, cmd.Name) - } - return out -} - -// CommandsByName returns all commands with the given name. -func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand { - var out []fakeDrawerCommand - for _, cmd := range d.commands { - if cmd.Name == name { - out = append(out, cmd) - } - } - return out -} - -// LastCommand returns the last recorded command and whether it exists. -func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) { - if len(d.commands) == 0 { - return fakeDrawerCommand{}, false - } - return d.commands[len(d.commands)-1], true -} - -// CurrentState returns a defensive copy of the current fake state. -func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState { - return d.state.clone() -} - -// SaveDepth returns the current Save/Restore nesting depth. -func (d *fakePrimitiveDrawer) SaveDepth() int { - return len(d.stack) -} - -// ResetLog clears only the command log and keeps the current state intact. -func (d *fakePrimitiveDrawer) ResetLog() { - d.commands = nil -} - -func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) { - d.snapshotCommand("CopyShift", float64(dx), float64(dy)) -} - -func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) { - // Store as a command; tests usually only care that it was called. - d.snapshotCommand("ClearAllTo") -} - -func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) { - d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h)) -} - -func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) { - d.snapshotCommand("DrawImage", float64(x), float64(y)) -} - -func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) { - d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h)) -} -func (d *fakePrimitiveDrawer) Reset() { - d.mu.Lock() - defer d.mu.Unlock() - d.commands = d.commands[:0] -} - -// requireDrawerCommandNames asserts the exact command sequence recorded -// by fakePrimitiveDrawer. -func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) { - t.Helper() - - require.Equal(t, want, d.CommandNames()) -} - -// requireDrawerCommandCount asserts the number of recorded commands. -func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) { - t.Helper() - - require.Len(t, d.Commands(), want) -} - -// requireDrawerCommandAt returns the command at the specified index. -func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmds := d.Commands() - require.GreaterOrEqual(t, index, 0) - require.Less(t, index, len(cmds)) - - return cmds[index] -} - -// requireDrawerSingleCommand returns the only command with the given name. -func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand { - t.Helper() - - cmds := d.CommandsByName(name) - require.Len(t, cmds, 1) - - return cmds[0] -} - -// requireCommandName asserts the command name. -func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) { - t.Helper() - - require.Equal(t, want, cmd.Name) -} - -// requireCommandArgs asserts the exact float arguments. -func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) { - t.Helper() - - require.Equal(t, want, cmd.Args) -} - -// requireCommandArgsInDelta asserts the float arguments with tolerance. -func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) { - t.Helper() - - require.Len(t, cmd.Args, len(want)) - for i := range want { - require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i) - } -} - -// requireCommandClipRects asserts the clip stack snapshot attached to the command. -func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) { - t.Helper() - - require.Equal(t, want, cmd.Clips) -} - -// requireCommandLineWidth asserts the line width snapshot attached to the command. -func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) { - t.Helper() - - require.Equal(t, want, cmd.LineWidth) -} - -// requireCommandDashes asserts the dash snapshot attached to the command. -func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) { - t.Helper() - - require.Equal(t, want, cmd.Dashes) -} - -// requireCommandDashOffset asserts the dash offset snapshot attached to the command. -func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) { - t.Helper() - - require.Equal(t, want, cmd.DashOffset) -} diff --git a/client/world/hit.go b/client/world/hit.go deleted file mode 100644 index b612125..0000000 --- a/client/world/hit.go +++ /dev/null @@ -1,225 +0,0 @@ -package world - -import ( - "sort" -) - -// PrimitiveKind identifies primitive types in hit-test results. -type PrimitiveKind uint8 - -const ( - KindLine PrimitiveKind = iota - KindCircle - KindPoint -) - -// Hit describes one primitive that matches a hit-test query. -type Hit struct { - ID PrimitiveID - Kind PrimitiveKind - Priority int - StyleID StyleID - - // DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort). - // Used for tie-breaking (smaller is better). - DistanceSq u128 - - // Primitive world coordinates: - // - Point: X,Y set - // - Circle: X,Y,Radius set - // - Line: X1,Y1,X2,Y2 set - X, Y int - Radius int - X1, Y1 int - X2, Y2 int -} - -// Default hit slop (in pixels) per primitive type. -const ( - DefaultHitSlopLinePx = 6 - DefaultHitSlopCirclePx = 6 - DefaultHitSlopPointPx = 8 - - // If a circle's screen radius is below this threshold, treat it as point-like for hit testing. - CirclePointLikeMinRadiusPx = 3 -) - -// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop. -// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations). -// -// If cap(out) is too small, it returns only the best hits by ranking: -// -// Priority desc, Distance asc, Kind asc, ID asc. -// -// Notes: -// - cursorXPx/cursorYPx are relative to viewport top-left. -// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll). -func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) { - if err := params.Validate(); err != nil { - return nil, err - } - if w.grid == nil || w.rows == 0 || w.cols == 0 { - return nil, errGridNotBuilt - } - - zoomFp, err := params.CameraZoomFp() - if err != nil { - return nil, err - } - - allowWrap := true - if params.Options != nil && params.Options.DisableWrapScroll { - allowWrap = false - } - - // Use clamped camera in no-wrap mode for consistency. - camX := params.CameraXWorldFp - camY := params.CameraYWorldFp - if !allowWrap { - camX, camY = ClampCameraNoWrapViewport( - camX, camY, - params.ViewportWidthPx, params.ViewportHeightPx, - zoomFp, - w.W, w.H, - ) - } - - // Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera). - worldPerPx := PixelSpanToWorldFixed(1, zoomFp) - offXPx := cursorXPx - params.ViewportWidthPx/2 - offYPx := cursorYPx - params.ViewportHeightPx/2 - - cursorX := camX + offXPx*worldPerPx - cursorY := camY + offYPx*worldPerPx - - if allowWrap { - cursorX = wrap(cursorX, w.W) - cursorY = wrap(cursorY, w.H) - } else { - // Clamp cursor into world bounds to avoid weird negative coords in margins. - cursorX = clamp(cursorX, 0, w.W-1) - cursorY = clamp(cursorY, 0, w.H-1) - } - - // Compute a conservative search bbox around cursor using max possible slop (px->world). - // We use the maximum of default slops; per-object overrides are handled later. - maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx)) - maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp) - - minX := cursorX - maxSlopWorld - maxX := cursorX + maxSlopWorld + 1 - minY := cursorY - maxSlopWorld - maxY := cursorY + maxSlopWorld + 1 - - var rects []Rect - if allowWrap { - rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY) - } else { - // Clamp to world. - minX = clamp(minX, 0, w.W) - maxX = clamp(maxX, 0, w.W) - minY = clamp(minY, 0, w.H) - maxY = clamp(maxY, 0, w.H) - if maxX <= minX || maxY <= minY { - return out[:0], nil - } - rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}} - } - - // Gather candidates from grid cells, dedupe by ID. - cand := make(map[PrimitiveID]struct{}, 32) - for _, r := range rects { - colStart := w.worldToCellX(r.minX) - colEnd := w.worldToCellX(r.maxX - 1) - rowStart := w.worldToCellY(r.minY) - rowEnd := w.worldToCellY(r.maxY - 1) - - for row := rowStart; row <= rowEnd; row++ { - for col := colStart; col <= colEnd; col++ { - cell := w.grid[row][col] - for _, it := range cell { - cand[it.ID()] = struct{}{} - } - } - } - } - - // Use caller buffer as backing store; keep only best cap(out) hits. - out = out[:0] - limit := cap(out) - - for id := range cand { - cur, ok := w.objects[id] - if !ok { - continue - } - h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap) - if !ok { - continue - } - - if limit == 0 { - // Caller provided zero-cap buffer; cannot store anything. - continue - } - - if len(out) < limit { - out = append(out, h) - continue - } - - // Replace the worst hit if the new one is better. - worstIdx := 0 - for i := 1; i < len(out); i++ { - if hitLess(out[worstIdx], out[i]) { - worstIdx = i // out[i] is worse than out[worstIdx] - } - } - if hitLess(h, out[worstIdx]) { - out[worstIdx] = h - } - } - - // Sort final hits by best-first order. - sort.Slice(out, func(i, j int) bool { - return hitLess(out[i], out[j]) - }) - - return out, nil -} - -// hitLess orders hits by: -// Priority desc, DistanceSq asc, Kind asc, ID asc. -func hitLess(a, b Hit) bool { - if a.Priority != b.Priority { - return a.Priority > b.Priority - } - if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 { - return c < 0 - } - if a.Kind != b.Kind { - return a.Kind < b.Kind - } - return a.ID < b.ID -} - -func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) { - switch v := it.(type) { - case Point: - return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H) - - case Circle: - style, ok := w.styles.Get(v.StyleID) - if !ok { - // Unknown style should not happen; treat as no-hit rather than panic. - return Hit{}, false - } - return hitCircle(v, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), style, cx, cy, zoomFp, allowWrap, w.W, w.H) - - case Line: - return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H) - - default: - panic("HitTest: unknown map item type") - } -} diff --git a/client/world/hit_test.go b/client/world/hit_test.go deleted file mode 100644 index c76ade8..0000000 --- a/client/world/hit_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package world - -import ( - "github.com/stretchr/testify/require" - "image/color" - "testing" -) - -// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits. -func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - // Build index once renderer state is initialized. - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) - - // Add overlapping objects near center. - idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) - require.NoError(t, err) - - idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) - require.NoError(t, err) - - idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200)) - require.NoError(t, err) - - // Force index rebuild from last state (Add already does it, but keep explicit). - w.Reindex() - - buf := make([]Hit, 0, 8) - hits, err := w.HitTest(buf, ¶ms, 50, 50) // center of viewport - require.NoError(t, err) - - // Should find all three, best first (priority desc). - require.Len(t, hits, 3) - require.Equal(t, idCircle, hits[0].ID) - require.Equal(t, idPoint, hits[1].ID) - require.Equal(t, idLine, hits[2].ID) -} - -// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits. -func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) - - _, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) - idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) - _, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200)) - w.Reindex() - - // Only room for 1 hit => must keep the best (highest priority). - buf := make([]Hit, 0, 1) - hits, err := w.HitTest(buf, ¶ms, 50, 50) - require.NoError(t, err) - require.Len(t, hits, 1) - require.Equal(t, idCircle, hits[0].ID) -} - -// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits. -func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 25, - MarginYPx: 25, - CameraXWorldFp: -100000, // invalid camera, should be clamped - CameraYWorldFp: -100000, - CameraZoom: 1.0, - Options: &RenderOptions{DisableWrapScroll: true}, - } - w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) - - _, err := w.AddPoint(0.0, 0.0, PointWithPriority(100)) - require.NoError(t, err) - w.Reindex() - - // Tap near top-left of viewport should still map to world and find the point. - buf := make([]Hit, 0, 8) - hits, err := w.HitTest(buf, ¶ms, 0, 0) - require.NoError(t, err) - require.NotEmpty(t, hits) -} - -// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center. -func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) - - // Stroke-only circle: FillColor alpha=0 => ring mode. - ov := StyleOverride{ - FillColor: color.RGBA{A: 0}, - StrokeColor: color.RGBA{A: 255}, - } - strokeStyle := w.AddStyleCircle(ov) - - _, err := w.AddCircle(5.0, 5.0, 2.0, - CircleWithStyleID(strokeStyle), - CircleWithPriority(100), - ) - require.NoError(t, err) - - w.Reindex() - - buf := make([]Hit, 0, 8) - - // Center must NOT hit. - hits, err := w.HitTest(buf, ¶ms, 50, 50) - require.NoError(t, err) - require.Empty(t, hits) - - // Near ring should hit. For small circles we use a minimum visible ring radius (3px). - // So tapping at +3px from center should be within ring+slop. - hits, err = w.HitTest(buf, ¶ms, 50+3, 50) - require.NoError(t, err) - require.NotEmpty(t, hits) - require.Equal(t, KindCircle, hits[0].Kind) -} - -// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area. -func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults - w.IndexOnViewportChange(100, 100, 1.0) - - // raw radius=2 units, centered at (5,5) - _, err := w.AddCircle(5, 5, 2) - require.NoError(t, err) - - // scale=2 => eff radius=4 - require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE)) - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - // Tap at +4 px from center should hit (eff radius 4). - buf := make([]Hit, 0, 8) - hits, err := w.HitTest(buf, ¶ms, 50+4, 50) - require.NoError(t, err) - require.NotEmpty(t, hits) - require.Equal(t, KindCircle, hits[0].Kind) - - // Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options. - // We'll add a small-slope circle and test deterministically. -} - -// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table. -func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) { - t.Parallel() - - type tc struct { - name string - fillVisible bool - rawRadius int // world units (not fixed); zoom=1 => 1px per unit - scaleFp int - hitSlopPx int - cursorDxPx int // offset from center in pixels along X axis - wantHit bool - wantKind PrimitiveKind - } - - // Common settings: world 20x20, viewport 200x200, camera at center (10,10). - params := RenderParams{ - ViewportWidthPx: 200, - ViewportHeightPx: 200, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 10 * SCALE, - CameraYWorldFp: 10 * SCALE, - CameraZoom: 1.0, - } - - tests := []tc{ - { - name: "filled: on boundary hits (R=4, S=1, dx=4)", - fillVisible: true, - rawRadius: 2, - scaleFp: 2 * SCALE, // eff radius = 4 - hitSlopPx: 1, - cursorDxPx: 4, - wantHit: true, - wantKind: KindCircle, - }, - { - name: "filled: outside beyond slop misses (R=4, S=1, dx=6)", - fillVisible: true, - rawRadius: 2, - scaleFp: 2 * SCALE, - hitSlopPx: 1, - cursorDxPx: 6, // 6 > R+S = 5 - wantHit: false, - }, - { - name: "filled: just inside slop hits (R=4, S=1, dx=5)", - fillVisible: true, - rawRadius: 2, - scaleFp: 2 * SCALE, - hitSlopPx: 1, - cursorDxPx: 5, // == R+S - wantHit: true, - wantKind: KindCircle, - }, - { - name: "stroke-only: center must miss even if slop would cover", - fillVisible: false, - rawRadius: 2, - scaleFp: 2 * SCALE, // eff radius = 4 - hitSlopPx: 10, // huge, would normally include center without our rule - cursorDxPx: 0, - wantHit: false, - }, - { - name: "stroke-only: on ring hits (R=4, S=1, dx=4)", - fillVisible: false, - rawRadius: 2, - scaleFp: 2 * SCALE, - hitSlopPx: 1, - cursorDxPx: 4, - wantHit: true, - wantKind: KindCircle, - }, - { - name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)", - fillVisible: false, - rawRadius: 2, - scaleFp: 2 * SCALE, - hitSlopPx: 1, - cursorDxPx: 2, // 2 < R-S = 3 - wantHit: false, - }, - { - name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)", - fillVisible: false, - rawRadius: 2, - scaleFp: 2 * SCALE, - hitSlopPx: 1, - cursorDxPx: 6, // 6 > R+S = 5 - wantHit: false, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w := NewWorld(20, 20) - w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) - - require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp)) - - // Build a stroke-only circle style if needed. - var opts []CircleOpt - opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx)) - - if !tt.fillVisible { - // Force fill alpha=0 => stroke-only for hit-test and rendering. - sw := 1.0 - styleID := w.AddStyleCircle(StyleOverride{ - FillColor: color.RGBA{A: 0}, - StrokeColor: color.RGBA{A: 255}, - StrokeWidthPx: &sw, - }) - opts = append(opts, CircleWithStyleID(styleID)) - } - - _, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...) - require.NoError(t, err) - - w.Reindex() - - // Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit. - cx := params.ViewportWidthPx/2 + tt.cursorDxPx - cy := params.ViewportHeightPx / 2 - - buf := make([]Hit, 0, 8) - hits, err := w.HitTest(buf, ¶ms, cx, cy) - require.NoError(t, err) - - if !tt.wantHit { - require.Empty(t, hits) - return - } - - require.NotEmpty(t, hits) - require.Equal(t, tt.wantKind, hits[0].Kind) - }) - } -} diff --git a/client/world/renderer.go b/client/world/renderer.go deleted file mode 100644 index 5f081d1..0000000 --- a/client/world/renderer.go +++ /dev/null @@ -1,1889 +0,0 @@ -package world - -import ( - "errors" - "image" - "image/color" - "sort" - "sync" - "time" -) - -// RenderLayer identifies one drawing pass. -type RenderLayer int - -const ( - RenderLayerPoints RenderLayer = iota - RenderLayerCircles - RenderLayerLines -) - -const ( - drawPlanSinglePassClipEnabled = false - - // best value according to BenchmarkDrawPlanSinglePass_Lines_GG - maxLineSegmentsPerStroke = 32 -) - -// RenderOptions controls which layers are rendered and their order. -// If Layers is empty, the default order is: Points, Circles, Lines. -type RenderOptions struct { - Layers []RenderLayer - 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 - - // BackgroundColor is used to clear full redraw and dirty regions. - // If nil, default background is opaque black. - BackgroundColor color.Color -} - -var ( - errInvalidViewportSize = errors.New("render: invalid viewport size") - errInvalidMargins = errors.New("render: invalid margins") - errNilDrawer = errors.New("render: nil drawer") -) - -// RenderParams describes one render request coming from the UI layer. -// -// Camera coordinates are expressed in world fixed-point units and point to the -// center of the visible viewport. Margins are expressed in canvas pixels and -// extend the rendered area around the viewport on each axis independently. -// -// The final canvas size is derived from viewport size and margins: -// -// canvasWidthPx = viewportWidthPx + 2*marginXPx -// canvasHeightPx = viewportHeightPx + 2*marginYPx -type RenderParams struct { - ViewportWidthPx int - ViewportHeightPx int - - MarginXPx int - MarginYPx int - - CameraXWorldFp int - CameraYWorldFp int - - CameraZoom float64 - - // Optional render options. If nil, defaults are used. - Options *RenderOptions - - // Used for various debugging purposes - Debug bool -} - -// CanvasWidthPx returns the full expanded canvas width in pixels. -func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx } - -// CanvasHeightPx returns the full expanded canvas height in pixels. -func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx } - -// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form. -func (p RenderParams) CameraZoomFp() (int, error) { - return CameraZoomToWorldFixed(p.CameraZoom) -} - -// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by -// the full expanded canvas around the camera center. -// -// The returned rectangle is expressed in fixed-point world coordinates and is not -// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis; -// torus normalization and tiling are handled later by the renderer pipeline. -func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) { - zoomFp, err := p.CameraZoomFp() - if err != nil { - return Rect{}, err - } - - return expandedCanvasWorldRect( - p.CameraXWorldFp, - p.CameraYWorldFp, - p.CanvasWidthPx(), - p.CanvasHeightPx(), - zoomFp, - ), nil -} - -// Validate checks whether the render request is internally consistent. -// Camera coordinates are intentionally not restricted here because the renderer -// is expected to normalize them through torus wrap. -func (p RenderParams) Validate() error { - if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 { - return errInvalidViewportSize - } - if p.MarginXPx < 0 || p.MarginYPx < 0 { - return errInvalidMargins - } - - if _, err := p.CameraZoomFp(); err != nil { - return err - } - - if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 { - return errInvalidViewportSize - } - - return nil -} - -// expandedCanvasWorldRect computes the world-space half-open rectangle covered by -// a full expanded canvas centered on the camera. -// -// The rectangle is returned in fixed-point world coordinates and is not wrapped. -// A later renderer step is expected to tile and normalize it against torus bounds. -func expandedCanvasWorldRect( - cameraXWorldFp, cameraYWorldFp int, - canvasWidthPx, canvasHeightPx int, - zoomFp int, -) Rect { - if canvasWidthPx <= 0 || canvasHeightPx <= 0 { - panic("expandedCanvasWorldRect: invalid canvas size") - } - if zoomFp <= 0 { - panic("expandedCanvasWorldRect: invalid zoom") - } - - worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp) - worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp) - - minX := cameraXWorldFp - worldWidthFp/2 - minY := cameraYWorldFp - worldHeightFp/2 - - return Rect{ - minX: minX, - maxX: minX + worldWidthFp, - minY: minY, - maxY: minY + worldHeightFp, - } -} - -// Render draws the current world state onto the expanded canvas represented by drawer. -// -// Stage A implementation is expected to perform a full redraw of the entire -// expanded canvas. Incremental scrolling and canvas shifting are intentionally -// left for later stages. -// -// The renderer must treat the camera as looking at the center of the viewport, -// not the center of the full expanded canvas. -// -// The renderer performs three passes (layers) in a configurable order. -// The render plan (tiling + candidates + clips) is built once and reused. -func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { - if drawer == nil { - return errNilDrawer - } - if err := params.Validate(); err != nil { - return err - } - - var bg color.Color = color.RGBA{A: 255} // default black - - if params.Options != nil && params.Options.BackgroundColor != nil { - bg = params.Options.BackgroundColor - } else { - tc := w.Theme().BackgroundColor() - if alphaNonZero(tc) { - bg = tc - } - } - - allowWrap := params.Options == nil || !params.Options.DisableWrapScroll - - defer func() { - if !params.Debug { - return - } - drawer.AddLine( - float64(params.MarginXPx), - float64(params.MarginYPx), - float64(params.MarginXPx+params.ViewportWidthPx), - float64(params.MarginYPx)) - drawer.AddLine( - float64(params.MarginXPx), - float64(params.MarginYPx), - float64(params.MarginXPx), - float64(params.MarginYPx+params.ViewportHeightPx)) - drawer.AddLine( - float64(params.MarginXPx+params.ViewportWidthPx), - float64(params.MarginYPx), - float64(params.MarginXPx+params.ViewportWidthPx), - float64(params.MarginYPx+params.ViewportHeightPx)) - drawer.AddLine( - float64(params.MarginXPx), - float64(params.MarginYPx+params.ViewportHeightPx), - float64(params.MarginXPx+params.ViewportWidthPx), - float64(params.MarginYPx+params.ViewportHeightPx)) - }() - - startTs := time.Now() - defer func() { - // record dtRender for future overload heuristics - w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds() - }() - - policy := DefaultIncrementalPolicy() - if params.Options != nil && params.Options.Incremental != nil { - policy = *params.Options.Incremental - } - - // Helper: draw one dirty rect with outer clip, using an already prepared dirtyPlan. - // IMPORTANT: dirtyPlan must be built from the FULL set of dirty rects (union), - // not from a single rect, to avoid missing primitives on diagonal pans. - drawDirtyRect := func(dirtyPlan RenderPlan, r RectPx) error { - if r.W <= 0 || r.H <= 0 { - return nil - } - - drawer.Save() - drawer.ResetClip() - drawer.ClipRect(float64(r.X), float64(r.Y), float64(r.W), float64(r.H)) - - // Clear + background in the same clip. - drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg) - w.drawBackground(drawer, params, r) - - // Draw with outer clip only; do not rebuild plan per-rect. - // isDirtyPass MUST be true here. - w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap, drawPlanSinglePassClipEnabled, true) - - drawer.Restore() - return nil - } - - // --- Try incremental path first when state is initialized and geometry matches --- - dxPx, dyPx, derr := w.ComputePanShiftPx(params) - if derr == nil { - inc, perr := PlanIncrementalPan( - params.CanvasWidthPx(), - params.CanvasHeightPx(), - params.MarginXPx, - params.MarginYPx, - dxPx, - dyPx, - ) - if perr != nil { - return perr - } - - switch inc.Mode { - case IncrementalNoOp: - // If we accumulated dirty regions during shift-only frames, redraw them now (bounded). - if len(w.renderState.pendingDirty) > 0 { - toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) - w.renderState.pendingDirty = remaining - - if len(toDraw) == 0 { - return nil - } - - plan, err := w.buildRenderPlan(params) - if err != nil { - return err - } - - // Build once for the whole set of catch-up rects (union), then clip per rect. - catchUpPlan := planRestrictedToDirtyRects(plan, toDraw) - - for _, r := range toDraw { - if err := drawDirtyRect(catchUpPlan, r); err != nil { - return err - } - } - } - return nil - - case IncrementalShift: - // Move existing pending dirty rects together with the backing image shift. - if len(w.renderState.pendingDirty) > 0 { - moved := make([]RectPx, 0, len(w.renderState.pendingDirty)) - for _, r := range w.renderState.pendingDirty { - if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok { - moved = append(moved, rr) - } - } - w.renderState.pendingDirty = moved - } - // Shift backing pixels first. - drawer.CopyShift(inc.DxPx, inc.DyPx) - - overBudget := false - if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 { - budgetNs := int64(policy.RenderBudgetMs) * 1_000_000 - if w.renderState.lastRenderDurationNs > budgetNs { - overBudget = true - } - } - if overBudget { - // Shift-only: defer drawing; remember newly exposed strips. - if len(inc.Dirty) > 0 { - w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...) - } - return nil - } - - // Under budget: draw newly exposed strips immediately, plus bounded catch-up. - dirtyToDraw := inc.Dirty - - // Additionally redraw a bounded portion of deferred dirty regions. - if len(w.renderState.pendingDirty) > 0 { - catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) - dirtyToDraw = append(dirtyToDraw, catchUp...) - w.renderState.pendingDirty = remaining - } - - if len(dirtyToDraw) == 0 { - return nil - } - - plan, err := w.buildRenderPlan(params) - if err != nil { - return err - } - - // Build once for the union of all dirty rects. - dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw) - - // Draw per-rect with outer clip; background/clear done inside helper. - for _, r := range dirtyToDraw { - if err := drawDirtyRect(dirtyPlan, r); err != nil { - return err - } - } - return nil - - case IncrementalFullRedraw: - // Fall through to full redraw below. - default: - panic("render: unknown incremental mode") - } - } - - // --- Full redraw path --- - plan, err := w.buildRenderPlan(params) - if err != nil { - return err - } - - drawer.ClearAllTo(bg) - w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()}) - w.drawPlanSinglePass(drawer, plan, allowWrap, drawPlanSinglePassClipEnabled, true) - return w.CommitFullRedrawState(params) -} - -// ForceFullRedrawNext resets internal incremental renderer state. -// After this call, the next Render() will use the full redraw path and -// re-initialize incremental state. -func (w *World) ForceFullRedrawNext() { - w.renderState.Reset() -} - -// WorldTile describes one torus tile contribution for an unwrapped world rect. -// -// Rect is the portion of the unwrapped rect mapped into the canonical world domain -// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle. -// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height) -// that map this canonical rect back into the unwrapped coordinate space. -type WorldTile struct { - Rect Rect - OffsetX int - OffsetY int -} - -// tileWorldRect splits an unwrapped world-space rect into a set of tiles, -// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp). -// -// Unlike splitByWrap, this function does NOT collapse spans wider than the world. -// If rect spans multiple world widths/heights, it returns multiple tiles. -// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index. -func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile { - if worldWidthFp <= 0 || worldHeightFp <= 0 { - panic("tileWorldRect: non-positive world size") - } - - width := rect.maxX - rect.minX - height := rect.maxY - rect.minY - if width <= 0 || height <= 0 { - return nil - } - - // Determine which torus tiles the rect intersects. - // Since rect is half-open, use (max-1) for inclusive end. - minTileX := floorDiv(rect.minX, worldWidthFp) - maxTileX := floorDiv(rect.maxX-1, worldWidthFp) - minTileY := floorDiv(rect.minY, worldHeightFp) - maxTileY := floorDiv(rect.maxY-1, worldHeightFp) - - out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1)) - - for tx := minTileX; tx <= maxTileX; tx++ { - tileBaseX := tx * worldWidthFp - segMinX := max(rect.minX, tileBaseX) - segMaxX := min(rect.maxX, tileBaseX+worldWidthFp) - if segMinX >= segMaxX { - continue - } - - localMinX := segMinX - tileBaseX - localMaxX := segMaxX - tileBaseX - - for ty := minTileY; ty <= maxTileY; ty++ { - tileBaseY := ty * worldHeightFp - segMinY := max(rect.minY, tileBaseY) - segMaxY := min(rect.maxY, tileBaseY+worldHeightFp) - if segMinY >= segMaxY { - continue - } - - localMinY := segMinY - tileBaseY - localMaxY := segMaxY - tileBaseY - - out = append(out, WorldTile{ - Rect: Rect{ - minX: localMinX, maxX: localMaxX, - minY: localMinY, maxY: localMaxY, - }, - OffsetX: tileBaseX, - OffsetY: tileBaseY, - }) - } - } - - 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, - }, - } -} - -// isEmptyRectPx reports whether r covers no canvas pixels. -func isEmptyRectPx(r RectPx) bool { - return r.W <= 0 || r.H <= 0 -} - -// intersectRectPx returns the intersection of two half-open canvas rectangles. -func intersectRectPx(a, b RectPx) (RectPx, bool) { - ax2 := a.X + a.W - ay2 := a.Y + a.H - bx2 := b.X + b.W - by2 := b.Y + b.H - - x1 := max(a.X, b.X) - y1 := max(a.Y, b.Y) - x2 := min(ax2, bx2) - y2 := min(ay2, by2) - - w := x2 - x1 - h := y2 - y1 - if w <= 0 || h <= 0 { - return RectPx{}, false - } - - return RectPx{X: x1, Y: y1, W: w, H: h}, true -} - -// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams. -// It is a pure description: it does not execute any drawing. -type RenderPlan struct { - CanvasWidthPx int - CanvasHeightPx int - - ZoomFp int - - // WorldRect is the unwrapped world-space rect covered by the expanded canvas. - WorldRect Rect - - // Tiles are ordered in the same order as produced by tileWorldRect: - // increasing tile X index, then increasing tile Y index. - Tiles []TileDrawPlan -} - -// TileDrawPlan describes how to draw one torus tile contribution. -type TileDrawPlan struct { - Tile WorldTile - - // Clip rect on the expanded canvas in pixel coordinates. - // It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH). - ClipX int - ClipY int - ClipW int - ClipH int - - // Candidates are unique per tile (deduped by ID). - Candidates []MapItem -} - -// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span -// for the given fixed-point zoom. The conversion is truncating (floor). -func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int { - // spanWorldFp can be negative in some internal cases, but for clip computations - // we always pass non-negative spans. - return (spanWorldFp * zoomFp) / (SCALE * SCALE) -} - -// buildRenderPlan builds a full expanded-canvas redraw plan. -// -// It assumes the world grid is already built (IndexOnViewportChange called). -// The plan contains per-tile clip rectangles and per-tile candidate lists -// from the spatial index. -func (w *World) buildRenderPlan(params RenderParams) (RenderPlan, error) { - if err := params.Validate(); err != nil { - return RenderPlan{}, err - } - - zoomFp, err := params.CameraZoomFp() - if err != nil { - return RenderPlan{}, err - } - - worldRect, err := params.ExpandedCanvasWorldRect() - if err != nil { - return RenderPlan{}, err - } - - 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) - if err != nil { - return RenderPlan{}, err - } - - planTiles := make([]TileDrawPlan, 0, len(batches)) - - for _, batch := range batches { - tile := batch.Tile - - // Convert the tile's canonical rect + offsets into the unwrapped segment. - segMinX := tile.Rect.minX + tile.OffsetX - segMaxX := tile.Rect.maxX + tile.OffsetX - segMinY := tile.Rect.minY + tile.OffsetY - segMaxY := tile.Rect.maxY + tile.OffsetY - - // Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY. - clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp) - clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp) - clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp) - clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp) - - clipW := clipX2 - clipX - clipH := clipY2 - clipY - - planTiles = append(planTiles, TileDrawPlan{ - Tile: tile, - ClipX: clipX, - ClipY: clipY, - ClipW: clipW, - ClipH: clipH, - Candidates: batch.Items, - }) - } - - return RenderPlan{ - CanvasWidthPx: params.CanvasWidthPx(), - CanvasHeightPx: params.CanvasHeightPx(), - ZoomFp: zoomFp, - WorldRect: worldRect, - Tiles: planTiles, - }, nil -} - -var ( - errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first") -) - -// TileCandidates binds one torus tile to the list of unique grid candidates -// that intersect the tile rectangle. -// -// Items are not guaranteed to be truly visible; the grid is a coarse spatial index. -// Exact visibility tests are performed later in the renderer pipeline. -type TileCandidates struct { - Tile WorldTile - Items []MapItem -} - -// collectCandidatesForTiles queries the world grid for each tile rectangle -// and returns per-tile unique candidate lists. -// -// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by -// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed. -func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) { - if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 { - return nil, errGridNotBuilt - } - - out := make([]TileCandidates, 0, len(tiles)) - for _, tile := range tiles { - items := w.collectCandidatesForTile(tile.Rect) - out = append(out, TileCandidates{ - Tile: tile, - Items: items, - }) - } - return out, nil -} - -// collectCandidatesForTile returns a unique set of grid candidates for a single -// canonical-world tile rectangle [0..W) x [0..H). -// -// The rectangle must be half-open and expressed in fixed-point world coordinates. -func (w *World) collectCandidatesForTile(r Rect) []MapItem { - // Empty rect => no candidates. - if r.maxX <= r.minX || r.maxY <= r.minY { - return nil - } - - // Map rect to cell ranges using the same half-open conventions as indexing: - // the last included cell is computed from (max-1). - colStart := w.worldToCellX(r.minX) - colEnd := w.worldToCellX(r.maxX - 1) - - rowStart := w.worldToCellY(r.minY) - rowEnd := w.worldToCellY(r.maxY - 1) - - // Start a new epoch for this tile dedupe. - w.candSeenResetIfOverflow() - - // Reuse result buffer. - out := w.scratchCandidates[:0] - - for row := rowStart; row <= rowEnd; row++ { - for col := colStart; col <= colEnd; col++ { - cell := w.grid[row][col] - for _, item := range cell { - id := item.ID() - if w.candSeenMark(id) { - continue - } - out = append(out, item) - } - } - } - - // Store back the reusable buffer (keep capacity). - w.scratchCandidates = out[:0] - - // IMPORTANT: - // We must return a stable slice to the caller (plan stores it). - // Returning `out` directly would be overwritten on the next tile. - // - // So: copy out into a freshly allocated slice OR into a plan-level scratch pool. - // For Step 1 we keep correctness: allocate exactly once per tile. - // Step 3 will remove this allocation by making plan own a pooled backing store. - res := make([]MapItem, len(out)) - copy(res, out) - return res -} - -// drawKind is used only for stable tie-breaking when priorities are equal. -type drawKind int - -const ( - drawKindLine drawKind = iota - drawKindCircle - drawKindPoint -) - -// drawItem is the normalized per-tile render record used for stable ordering. -// -// Each instance stores exactly one primitive payload together with the sort key -// that drawPlanSinglePass uses before issuing final drawer commands. -type drawItem struct { - kind drawKind - priority int - id PrimitiveID - styleID StyleID - - // Exactly one of these is set. - p Point - c Circle - l Line -} - -// drawPlanSinglePass renders a plan using a single ordered pass per tile. -// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism. -// -// allowWrap controls torus behavior: -// - true: circles/points produce wrap copies, lines use torus-shortest segments -// - false: no copies, lines drawn directly as stored -// tileClipEnabled controls whether per-tile ClipRect is applied. -// When an outer clip is already set (e.g. dirty rect), disable tile clips for speed. -func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool, tileClipEnabled bool, isDirtyPass bool) { - var lastStyleID StyleID = StyleIDInvalid - var lastStyle Style - - applyStyle := func(styleID StyleID) { - if styleID == lastStyleID { - return - } - s, ok := w.styles.Get(styleID) - if !ok { - panic("render: unknown style ID") - } - - if s.FillColor != nil { - drawer.SetFillColor(s.FillColor) - } - if s.StrokeColor != nil { - drawer.SetStrokeColor(s.StrokeColor) - } - drawer.SetLineWidth(s.StrokeWidthPx) - if len(s.StrokeDashes) > 0 { - drawer.SetDash(s.StrokeDashes...) - } else { - drawer.SetDash() - } - drawer.SetDashOffset(s.StrokeDashOffset) - - lastStyleID = styleID - lastStyle = s - } - - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - // Per-tile clip is optional. When outer-clip is used (dirty rect), - // tileClipEnabled must be false to avoid resetting the outer clip. - if tileClipEnabled { - drawer.Save() - drawer.ResetClip() - drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) - } - - items := w.scratchDrawItems[:0] - if cap(items) < len(td.Candidates) { - items = make([]drawItem, 0, len(td.Candidates)) - } - - for _, it := range td.Candidates { - id := it.ID() - cur, ok := w.objects[id] - if !ok { - continue - } - - switch v := cur.(type) { - case Point: - items = append(items, drawItem{ - kind: drawKindPoint, - priority: v.Priority, - id: v.Id, - styleID: v.StyleID, - p: v, - }) - case Circle: - items = append(items, drawItem{ - kind: drawKindCircle, - priority: v.Priority, - id: v.Id, - styleID: v.StyleID, - c: v, - }) - case Line: - items = append(items, drawItem{ - kind: drawKindLine, - priority: v.Priority, - id: v.Id, - styleID: v.StyleID, - l: v, - }) - default: - panic("render: unknown map item type") - } - } - - if len(items) == 0 { - if tileClipEnabled { - drawer.Restore() - } - w.scratchDrawItems = items[:0] - continue - } - - sort.Slice(items, func(i, j int) bool { - a, b := items[i], items[j] - if a.priority != b.priority { - return a.priority < b.priority - } - if a.kind != b.kind { - return a.kind < b.kind - } - return a.id < b.id - }) - - // If this is not a dirty pass (full redraw), keep the old behavior for lines: - // stroke per segment. This is usually faster for gg on huge scenes. - if !isDirtyPass { - for i := 0; i < len(items); i++ { - di := items[i] - applyStyle(di.styleID) - - switch di.kind { - case drawKindPoint: - w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle) - case drawKindCircle: - w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle) - case drawKindLine: - // Old behavior: drawLineInTile includes Stroke() per segment. - w.drawLineInTile(drawer, plan, td, di.l, allowWrap) - default: - panic("render: unknown draw kind") - } - } - } else { - // Dirty pass: batch lines to reduce overhead while panning. - inLineRun := false - var lineRunStyleID StyleID - lineSegCount := 0 - - flushLineRun := func() { - if !inLineRun { - return - } - drawer.Stroke() - inLineRun = false - lineSegCount = 0 - } - - for i := 0; i < len(items); i++ { - di := items[i] - - if inLineRun { - if di.kind != drawKindLine || di.styleID != lineRunStyleID { - flushLineRun() - } - } - - switch di.kind { - case drawKindLine: - if !inLineRun { - lineRunStyleID = di.styleID - applyStyle(lineRunStyleID) - inLineRun = true - } else { - // style matches by construction; keep style state valid if code changes later - applyStyle(di.styleID) - } - - added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap) - lineSegCount += added - - if lineSegCount >= maxLineSegmentsPerStroke { - drawer.Stroke() - lineSegCount = 0 - // keep run active - } - - case drawKindPoint: - flushLineRun() - applyStyle(di.styleID) - w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle) - - case drawKindCircle: - flushLineRun() - applyStyle(di.styleID) - w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle) - - default: - flushLineRun() - panic("render: unknown draw kind") - } - } - - flushLineRun() - } - - if tileClipEnabled { - drawer.Restore() - } - - // Reuse buffer for next tile. - w.scratchDrawItems = items[:0] - } -} - -// 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. -type lineSeg struct { - x1, y1 int - x2, y2 int -} - -// drawPointInTile draws point marker copies that intersect the tile. -// lastStyle is already applied; it provides PointRadiusPx. -func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) { - rPx := lastStyle.PointRadiusPx - if rPx <= 0 { - // Nothing visible. - return - } - - // Convert screen radius to world-fixed conservatively. - rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp) - - var shifts []wrapShift - if allowWrap { - shifts = pointWrapShifts(p, rWorldFp, w.W, w.H) - } else { - shifts = []wrapShift{{dx: 0, dy: 0}} - } - - for _, s := range shifts { - if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) { - continue - } - - px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp) - py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp) - - drawer.AddPoint(float64(px), float64(py), rPx) - - fill := alphaNonZero(lastStyle.FillColor) - stroke := alphaNonZero(lastStyle.StrokeColor) - - if fill { - drawer.Fill() - } - if stroke { - // Stroke must be last when both are present. - drawer.Stroke() - } - } -} - -func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) { - var shifts []wrapShift - effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp) - if allowWrap { - shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H) - } else { - var one [1]wrapShift - one[0] = wrapShift{dx: 0, dy: 0} - shifts = one[:] - } - - rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp) - - for _, s := range shifts { - if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) { - continue - } - - cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp) - cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp) - - fill := alphaNonZero(lastStyle.FillColor) - stroke := alphaNonZero(lastStyle.StrokeColor) - - switch { - case fill && stroke: - // gg consumes the current path on Fill/Stroke, so we must draw twice: - // once for fill, then again for stroke. - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - drawer.Fill() - - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - drawer.Stroke() - - case fill: - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - drawer.Fill() - - case stroke: - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - drawer.Stroke() - - default: - // neither visible => nothing - } - } - w.scratchWrapShifts = shifts[:0] -} - -func (w *World) drawLineInTilePath(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) int { - segs := w.scratchLineSegs[:0] - tmp := w.scratchLineSegsTmp[:0] - if cap(segs) < 4 { - segs = make([]lineSeg, 0, 4) - } - if cap(tmp) < 4 { - tmp = make([]lineSeg, 0, 4) - } - - if allowWrap { - segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H) - } else { - var one [1]lineSeg - one[0] = lineSeg{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2} - segs = one[:] - } - - for _, s := range segs { - x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) - y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) - x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) - y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) - - drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2)) - } - - w.scratchLineSegs = segs[:0] - w.scratchLineSegsTmp = tmp[:0] - - return len(segs) -} - -func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) { - w.drawLineInTilePath(drawer, plan, td, l, allowWrap) - drawer.Stroke() -} - -var ( - errInvalidCanvasSize = errors.New("incremental: invalid canvas size") -) - -// IncrementalMode describes how the renderer should update the backing image. -type IncrementalMode int - -const ( - // IncrementalNoOp means no visual change is needed (dx=0 and dy=0). - IncrementalNoOp IncrementalMode = iota - - // IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn. - IncrementalShift - - // IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw. - IncrementalFullRedraw -) - -// RectPx is an integer rectangle in canvas pixel coordinates. -// Semantics are half-open: [X, X+W) x [Y, Y+H). -type RectPx struct { - X, Y int - W, H int -} - -// IncrementalPolicy is a placeholder for future incremental tuning. -// It is intentionally not used in C2; we only fix geometry-based thresholding now. -type IncrementalPolicy struct { - // CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates). - // This will be implemented later; kept here as a placeholder to lock the API shape. - CoalesceUpdates bool - - // AllowShiftOnly allows a temporary mode where the backing image is shifted - // but dirty rects are not redrawn immediately under overload. - AllowShiftOnly bool - - // RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation. - RenderBudgetMs int - - // MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame. - // 0 means "no limit". - MaxCatchUpAreaPx int -} - -// IncrementalPlan is the output of pure incremental planning. -// It does not perform any drawing. It only describes what should happen. -type IncrementalPlan struct { - Mode IncrementalMode - - // Shift to apply to the backing image in canvas pixels. - // Positive dx shifts the existing image to the right (exposing a dirty strip on the left). - // Positive dy shifts the existing image down (exposing a dirty strip on the top). - DxPx int - DyPx int - - // Dirty rects to redraw after shifting (in canvas pixel coordinates). - // Rects may overlap; overlapping is allowed and simplifies planning. - Dirty []RectPx -} - -// PlanIncrementalPan computes whether the renderer can update by shifting the backing image -// and redrawing only exposed strips, or must fall back to a full redraw. -// -// Threshold rule (per-axis): -// - If abs(dxPx) > marginXPx/2 => full redraw -// - If abs(dyPx) > marginYPx/2 => full redraw -// -// Additional safety rules: -// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw -// -// Returned dirty rects follow the chosen shift direction: -// -// dxPx > 0 => dirty strip on the left (width=dxPx) -// dxPx < 0 => dirty strip on the right (width=-dxPx) -// dyPx > 0 => dirty strip on the top (height=dyPx) -// dyPx < 0 => dirty strip on the bottom(height=-dyPx) -func PlanIncrementalPan( - canvasW, canvasH int, - marginXPx, marginYPx int, - dxPx, dyPx int, -) (IncrementalPlan, error) { - if canvasW <= 0 || canvasH <= 0 { - return IncrementalPlan{}, errInvalidCanvasSize - } - if marginXPx < 0 || marginYPx < 0 { - return IncrementalPlan{}, errors.New("incremental: invalid margins") - } - - // No movement => no work. - if dxPx == 0 && dyPx == 0 { - return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil - } - - adx := abs(dxPx) - ady := abs(dyPx) - - // Too large shift can’t be represented as "shift + stripes". - if adx >= canvasW || ady >= canvasH { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - // Thresholds: per axis, independently. - // Using integer division: margin/2 truncates down, which is fine and deterministic. - thrX := marginXPx / 2 - thrY := marginYPx / 2 - - if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - // If margin is 0, thr is 0, and any non-zero delta should force full redraw - // (because we have no buffer area to shift into). - if marginXPx == 0 && dxPx != 0 { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - if marginYPx == 0 && dyPx != 0 { - return IncrementalPlan{Mode: IncrementalFullRedraw}, nil - } - - dirty := make([]RectPx, 0, 2) - - // Horizontal exposed strip with 1px overdraw to avoid seams. - if dxPx > 0 { - // Image moved right => left strip is exposed. - w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area - dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH}) - } else if dxPx < 0 { - // Image moved left => right strip is exposed. - w := min((-dxPx)+1, canvasW) - dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH}) - } - - // Vertical exposed strip with 1px overdraw to avoid seams. - if dyPx > 0 { - // Image moved down => top strip is exposed. - h := min(dyPx+1, canvasH) - dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h}) - } else if dyPx < 0 { - // Image moved up => bottom strip is exposed. - h := min((-dyPx)+1, canvasH) - dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h}) - } - - // Filter out any zero/negative rects defensively. - out := dirty[:0] - for _, r := range dirty { - if r.W <= 0 || r.H <= 0 { - continue - } - out = append(out, r) - } - - return IncrementalPlan{ - Mode: IncrementalShift, - DxPx: dxPx, - DyPx: dyPx, - Dirty: out, - }, nil -} - -// shiftAndClipRectPx moves r by the supplied pixel delta and clips it to the -// current canvas bounds. -func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) { - n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H} - inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH}) - return inter, ok -} - -// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries -// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own -// TileDrawPlan entry with the clip replaced by the intersection. -// -// This makes drawing functions naturally render only the dirty areas. -func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan { - if len(dirty) == 0 { - return RenderPlan{ - CanvasWidthPx: plan.CanvasWidthPx, - CanvasHeightPx: plan.CanvasHeightPx, - ZoomFp: plan.ZoomFp, - WorldRect: plan.WorldRect, - Tiles: nil, - } - } - - outTiles := make([]TileDrawPlan, 0) - - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH} - - for _, dr := range dirty { - if isEmptyRectPx(dr) { - continue - } - - inter, ok := intersectRectPx(tileClip, dr) - if !ok { - continue - } - - outTiles = append(outTiles, TileDrawPlan{ - Tile: td.Tile, - ClipX: inter.X, - ClipY: inter.Y, - ClipW: inter.W, - ClipH: inter.H, - Candidates: td.Candidates, - }) - } - } - - return RenderPlan{ - CanvasWidthPx: plan.CanvasWidthPx, - CanvasHeightPx: plan.CanvasHeightPx, - ZoomFp: plan.ZoomFp, - WorldRect: plan.WorldRect, - Tiles: outTiles, - } -} - -// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx. -// It returns (selected, remaining). If maxAreaPx <= 0, it selects all. -func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) { - if len(pending) == 0 { - return nil, nil - } - if maxAreaPx <= 0 { - // No limit. - all := append([]RectPx(nil), pending...) - return all, nil - } - - selected = make([]RectPx, 0, len(pending)) - remaining = make([]RectPx, 0) - - used := 0 - for _, r := range pending { - if r.W <= 0 || r.H <= 0 { - continue - } - area := r.W * r.H - if area <= 0 { - continue - } - - // If we cannot fit the whole rect, we stop (simple, deterministic). - // (We do not split rectangles here to keep logic simple.) - if used+area > maxAreaPx { - remaining = append(remaining, r) - continue - } - - selected = append(selected, r) - used += area - } - - // Also keep any rects we skipped due to invalid size (none) and those that didn't fit. - // Note: remaining preserves original order among non-selected entries. - return selected, remaining -} - -var ( - errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required") - errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required") - errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom") - errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size") -) - -// rendererIncrementalState stores the minimum state needed for incremental pan. -type rendererIncrementalState struct { - initialized bool - - // Last render geometry key. - lastZoomFp int - - lastViewportW int - lastViewportH int - lastMarginX int - lastMarginY int - lastCanvasW int - lastCanvasH int - - // Last unwrapped expanded world rect used for rendering. - lastWorldRect Rect - - // Remainders in numerator space to make world->px conversion stable across many small pans. - // We keep them per axis and update them during conversion. - remXNum int64 - remYNum int64 - - // Last measured render duration (nanoseconds). Used for overload heuristics. - lastRenderDurationNs int64 - - // Pending dirty areas accumulated during shift-only frames. - // These are in current canvas pixel coordinates. - pendingDirty []RectPx -} - -// Reset clears incremental state, forcing next frame to use full redraw. -func (s *rendererIncrementalState) Reset() { - *s = rendererIncrementalState{} -} - -// incrementalKeyFromParams extracts the geometry key that must match for incremental pan. -func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) { - vw = params.ViewportWidthPx - vh = params.ViewportHeightPx - mx = params.MarginXPx - my = params.MarginYPx - cw = params.CanvasWidthPx() - ch = params.CanvasHeightPx() - z = zoomFp - return -} - -// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp, -// carrying a signed remainder in numerator space to avoid cumulative drift. -// -// The conversion is: -// -// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE)) -// -// and rem is updated to the exact remainder. -// -// This function works for negative deltas too and uses floor division semantics. -func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int { - if zoomFp <= 0 { - panic("worldDeltaFixedToCanvasPx: invalid zoom") - } - - den := int64(SCALE) * int64(SCALE) - num := int64(deltaWorldFp)*int64(zoomFp) + *remNum - - q, r := floorDivRem64(num, den) - *remNum = r - return int(q) -} - -// floorDivRem64 returns (q,r) such that: -// -// q = floor(a / b), r = a - q*b -// -// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder). -func floorDivRem64(a, b int64) (q int64, r int64) { - if b <= 0 { - panic("floorDivRem64: non-positive divisor") - } - - q = a / b - r = a % b - if r != 0 && a < 0 { - q-- - r = a - q*b - } - return q, r -} - -// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image -// when ONLY camera pan changed (no zoom/viewport/margins changes). -// -// Returned dxPx/dyPx are shifts to apply to the already rendered image: -// -// dxPx > 0 => shift image right -// dxPx < 0 => shift image left -// -// This function updates internal incremental state when possible. -// If it returns an error, the caller should fall back to a full redraw and call -// CommitFullRedrawState afterward. -func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) { - zoomFp, zerr := params.CameraZoomFp() - if zerr != nil { - return 0, 0, zerr - } - if zoomFp <= 0 { - return 0, 0, errIncrementalInvalidZoomFp - } - - canvasW := params.CanvasWidthPx() - canvasH := params.CanvasHeightPx() - if canvasW <= 0 || canvasH <= 0 { - return 0, 0, errIncrementalInvalidCanvasPx - } - - newRect, rerr := params.ExpandedCanvasWorldRect() - if rerr != nil { - return 0, 0, rerr - } - - s := &w.renderState - - // First call: no prior state => must full redraw. - if !s.initialized { - return 0, 0, errIncrementalStateNotReady - } - - vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp) - if s.lastZoomFp != z || - s.lastViewportW != vw || s.lastViewportH != vh || - s.lastMarginX != mx || s.lastMarginY != my || - s.lastCanvasW != cw || s.lastCanvasH != ch { - return 0, 0, errIncrementalZoomMismatch - } - - // Compute how much the unwrapped world rect moved. - dMinX := newRect.minX - s.lastWorldRect.minX - dMinY := newRect.minY - s.lastWorldRect.minY - - // Convert world movement to pixel movement of the world content. - // If world rect moved +X (camera moved right), content appears shifted left, - // so the old image must be shifted left: shiftPx = -deltaPx. - deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum) - deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum) - - dxPx = -deltaPxX - dyPx = -deltaPxY - - // Update stored rect for the next incremental computation. - s.lastWorldRect = newRect - - return dxPx, dyPx, nil -} - -// CommitFullRedrawState updates incremental state after a full redraw. -// Call this after you finish a full Render() that draws the entire expanded canvas. -func (w *World) CommitFullRedrawState(params RenderParams) error { - zoomFp, err := params.CameraZoomFp() - if err != nil { - return err - } - if zoomFp <= 0 { - return errIncrementalInvalidZoomFp - } - - rect, err := params.ExpandedCanvasWorldRect() - if err != nil { - return err - } - - s := &w.renderState - vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp) - - s.initialized = true - s.lastZoomFp = z - s.lastViewportW = vw - s.lastViewportH = vh - s.lastMarginX = mx - s.lastMarginY = my - s.lastCanvasW = cw - s.lastCanvasH = ch - s.lastWorldRect = rect - - // Reset remainders on a full redraw to avoid stale accumulation when geometry changes. - s.remXNum = 0 - s.remYNum = 0 - - s.pendingDirty = nil - - return nil -} - -func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) { - if gd, ok := drawer.(*GGDrawer); ok { - if gd.drawBackgroundFast(w, params, rect) { - return - } - } - th := w.Theme() - bgImg := th.BackgroundImage() - if bgImg == nil { - return - } - - canvasW := params.CanvasWidthPx() - canvasH := params.CanvasHeightPx() - - // Clamp rect to canvas. - if rect.W <= 0 || rect.H <= 0 { - return - } - if rect.X < 0 { - rect.W += rect.X - rect.X = 0 - } - if rect.Y < 0 { - rect.H += rect.Y - rect.Y = 0 - } - if rect.X+rect.W > canvasW { - rect.W = canvasW - rect.X - } - if rect.Y+rect.H > canvasH { - rect.H = canvasH - rect.Y - } - if rect.W <= 0 || rect.H <= 0 { - return - } - - imgB := bgImg.Bounds() - imgW := imgB.Dx() - imgH := imgB.Dy() - if imgW <= 0 || imgH <= 0 { - return - } - - tileMode := th.BackgroundTileMode() - anchor := th.BackgroundAnchorMode() - scaleMode := th.BackgroundScaleMode() - - // Compute scaled tile size. - tileW, tileH := backgroundScaledSize(imgW, imgH, canvasW, canvasH, scaleMode) - if tileW <= 0 || tileH <= 0 { - return - } - - offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor) - - drawer.Save() - drawer.ResetClip() - drawer.ClipRect(float64(rect.X), float64(rect.Y), float64(rect.W), float64(rect.H)) - - switch tileMode { - case BackgroundTileNone: - // Center image within full canvas (not within rect), then clip handles partial. - // This is important so that dirty redraw matches full redraw. - x := (canvasW-tileW)/2 + offX - y := (canvasH-tileH)/2 + offY - drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode) - - case BackgroundTileRepeat: - originX := offX - originY := offY - - startX := floorDiv(rect.X-originX, tileW)*tileW + originX - startY := floorDiv(rect.Y-originY, tileH)*tileH + originY - - for yy := startY; yy < rect.Y+rect.H; yy += tileH { - for xx := startX; xx < rect.X+rect.W; xx += tileW { - drawBackgroundOne(drawer, bgImg, xx, yy, imgW, imgH, tileW, tileH, scaleMode) - } - } - - default: - // Fallback: behave like none. - x := (canvasW-tileW)/2 + offX - y := (canvasH-tileH)/2 + offY - drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode) - } - - drawer.Restore() -} - -// drawBackgroundOne draws one background-image instance at the requested -// canvas position, scaling it when needed. -func drawBackgroundOne(drawer PrimitiveDrawer, img image.Image, x, y, srcW, srcH, dstW, dstH int, scaleMode BackgroundScaleMode) { - if scaleMode == BackgroundScaleNone && dstW == srcW && dstH == srcH { - drawer.DrawImage(img, x, y) - return - } - // For Fit/Fill, or if dst size differs, draw scaled. - drawer.DrawImageScaled(img, x, y, dstW, dstH) -} - -// backgroundScaledSize computes uniform scaled destination size for the background image. -// For None: returns source size. -// For Fit: fits inside canvas. -// For Fill: covers canvas. -func backgroundScaledSize(srcW, srcH, canvasW, canvasH int, mode BackgroundScaleMode) (int, int) { - if srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0 { - return 0, 0 - } - - switch mode { - case BackgroundScaleNone: - return srcW, srcH - - case BackgroundScaleFit, BackgroundScaleFill: - // Uniform scale: choose ratio based on min/max. - // Use integer math to avoid float; keep it stable across frames. - // We compute scale as rational and then round destination size. - // Let scale = canvasW/srcW vs canvasH/srcH. - // Fit uses min(scaleW, scaleH). Fill uses max(scaleW, scaleH). - // - // We'll compute dstW = round(srcW*scale), dstH = round(srcH*scale). - // Using float64 here is acceptable: this is UI-only and deterministic enough, and we already use gg float. - scaleW := float64(canvasW) / float64(srcW) - scaleH := float64(canvasH) / float64(srcH) - - scale := scaleW - if mode == BackgroundScaleFit { - if scaleH < scale { - scale = scaleH - } - } else { - if scaleH > scale { - scale = scaleH - } - } - - dstW := int(scale*float64(srcW) + 0.5) - dstH := int(scale*float64(srcH) + 0.5) - if dstW < 1 { - dstW = 1 - } - if dstH < 1 { - dstH = 1 - } - return dstW, dstH - - default: - return srcW, srcH - } -} - -// backgroundAnchorOffsetPx computes a stable pixel offset for background anchoring. -// - Viewport anchor: offset is always 0 (background fixed to viewport/canvas pixels). -// - World anchor: offset depends on camera world position and zoom so that background moves with pan. -func (w *World) backgroundAnchorOffsetPx(params RenderParams, tileW, tileH int, anchor BackgroundAnchorMode) (int, int) { - if anchor == BackgroundAnchorViewport { - return 0, 0 - } - - zoomFp, err := params.CameraZoomFp() - if err != nil || zoomFp <= 0 { - return 0, 0 - } - - canvasW := params.CanvasWidthPx() - canvasH := params.CanvasHeightPx() - - spanW := PixelSpanToWorldFixed(canvasW, zoomFp) - spanH := PixelSpanToWorldFixed(canvasH, zoomFp) - - worldLeft := params.CameraXWorldFp - spanW/2 - worldTop := params.CameraYWorldFp - spanH/2 - - pxX := worldSpanFixedToCanvasPx(worldLeft, zoomFp) - pxY := worldSpanFixedToCanvasPx(worldTop, zoomFp) - - if tileW > 0 { - pxX = -wrap(pxX, tileW) - } - if tileH > 0 { - pxY = -wrap(pxY, tileH) - } - return pxX, pxY -} - -func (w *World) candSeenResetIfOverflow() { - w.candEpoch++ - if w.candEpoch != 0 { - return - } - // overflow: reset stamp array - for i := range w.candStamp { - w.candStamp[i] = 0 - } - w.candEpoch = 1 -} - -func (w *World) candSeenMark(id PrimitiveID) bool { - // ensure stamp capacity - uid := uint32(id) - if int(uid) >= len(w.candStamp) { - // grow to next power-ish - n := len(w.candStamp) - if n == 0 { - n = 1024 - } - for n <= int(uid) { - n *= 2 - } - ns := make([]uint32, n) - copy(ns, w.candStamp) - w.candStamp = ns - } - if w.candStamp[uid] == w.candEpoch { - return true - } - w.candStamp[uid] = w.candEpoch - return false -} - -// RenderScheduler is a toolkit-agnostic example of render-request coalescing. -// -// It keeps at most one render in flight and always collapses intermediate -// requests to the latest RenderParams snapshot. The scheduler is intentionally -// not a background renderer: real UI integrations must still execute World.Render -// on the UI thread and should replace the goroutine hand-off in runOnUIThread -// with toolkit-specific scheduling primitives. -// RenderScheduler keeps the latest requested RenderParams and serializes renders. -type RenderScheduler struct { - w *World - drawer PrimitiveDrawer - - // Protects fields below. - mu sync.Mutex - - inFlight bool - pending bool - latest RenderParams -} - -// RequestRender stores the latest params and schedules rendering. -// If a render is already in progress, it coalesces (drops intermediate requests). -func (s *RenderScheduler) RequestRender(params RenderParams) { - s.mu.Lock() - s.latest = params - if s.inFlight { - s.pending = true - s.mu.Unlock() - return - } - s.inFlight = true - s.mu.Unlock() - - // Schedule on the UI thread/event loop. Replace this with your toolkit method. - go s.runOnUIThread() -} - -// runOnUIThread renders the latest known params and repeats if newer params -// arrived while the previous render was running. -// -// The example body uses a goroutine only as a placeholder. Real applications -// should run the body on their UI event loop. -func (s *RenderScheduler) runOnUIThread() { - for { - s.mu.Lock() - params := s.latest - s.mu.Unlock() - - s.w.ClampRenderParamsNoWrap(¶ms) - _ = s.w.Render(s.drawer, params) // handle error in real code - - s.mu.Lock() - if !s.pending { - s.inFlight = false - s.mu.Unlock() - return - } - // There was a newer request while we were rendering. Loop and render latest. - s.pending = false - s.mu.Unlock() - } -} - -// RenderStyle describes visual parameters for renderer passes. -// It is intentionally screen-space oriented (pixels), since the renderer -// already projects world coordinates into canvas pixels. -type RenderStyle struct { - // PointRadiusPx is the screen-space radius for Point markers. - PointRadiusPx float64 - - // PointFill is the fill color for points. - PointFill color.Color - - // CircleFill is the fill color for circles. - CircleFill color.Color - - // LineStroke is the stroke color for lines. - LineStroke color.Color - - // LineWidthPx is the stroke width for lines. - LineWidthPx float64 - - // LineDash is the dash pattern for lines. Empty => solid. - LineDash []float64 - - // LineDashOffset is the dash phase for lines. - LineDashOffset float64 -} - -// DefaultRenderStyle returns the default style used when UI does not provide one. -// Defaults are intentionally simple and stable for testing. -func DefaultRenderStyle() RenderStyle { - return RenderStyle{ - PointRadiusPx: 2.0, - PointFill: color.White, - - CircleFill: color.White, - - LineStroke: color.White, - LineWidthPx: 2.0, - LineDash: nil, - LineDashOffset: 0, - } -} - -// DefaultIncrementalPolicy returns the default incremental pan policy. -// -// The zero-friction default is conservative: no shift-only degradation, no -// render-budget heuristics, and no catch-up area cap. -func DefaultIncrementalPolicy() IncrementalPolicy { - return IncrementalPolicy{ - CoalesceUpdates: false, - AllowShiftOnly: false, - RenderBudgetMs: 0, - MaxCatchUpAreaPx: 0, - } -} - -// applyPointStyle configures drawer state for point rendering. -func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) { - drawer.SetFillColor(style.PointFill) -} - -// applyCircleStyle configures drawer state for circle rendering. -func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) { - drawer.SetFillColor(style.CircleFill) -} - -// applyLineStyle configures drawer state for line rendering. -func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) { - drawer.SetStrokeColor(style.LineStroke) - drawer.SetLineWidth(style.LineWidthPx) - drawer.SetDash(style.LineDash...) - drawer.SetDashOffset(style.LineDashOffset) -} diff --git a/client/world/renderer_bench_test.go b/client/world/renderer_bench_test.go deleted file mode 100644 index b5514cc..0000000 --- a/client/world/renderer_bench_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package world - -import ( - "github.com/fogleman/gg" - "github.com/stretchr/testify/require" - "image" - "image/color" - "testing" -) - -type benchBgTheme struct { - img image.Image - anchor BackgroundAnchorMode - tileMode BackgroundTileMode - scaleMode BackgroundScaleMode -} - -func (t benchBgTheme) ID() string { return "benchbg" } -func (t benchBgTheme) Name() string { return "benchbg" } - -func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t benchBgTheme) BackgroundImage() image.Image { return t.img } - -func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode } -func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } -func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } - -func (t benchBgTheme) PointStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} -} -func (t benchBgTheme) LineStyle() Style { - return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (t benchBgTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background. -func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) { - w := NewWorld(600, 600) - w.IndexOnViewportChange(1200, 800, 1.0) - - // Some primitives to keep it realistic but not dominant. - for i := 0; i < 200; i++ { - _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) - } - w.Reindex() - - dc := gg.NewContext(1200, 800) - drawer := &GGDrawer{DC: dc} - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: &IncrementalPolicy{ - AllowShiftOnly: false, - CoalesceUpdates: false, - MaxCatchUpAreaPx: 0, - RenderBudgetMs: 0, - }, - }, - } - - // Initial render (commit state). - _ = w.Render(drawer, params) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - params.CameraXWorldFp += 1 * SCALE - _ = w.Render(drawer, params) - } -} - -// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None. -func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) { - benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone) -} - -// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit. -func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) { - benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit) -} - -// BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None. -func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) { - benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone) -} - -func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) { - w := NewWorld(600, 600) - w.IndexOnViewportChange(1200, 800, 1.0) - - for i := 0; i < 200; i++ { - _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) - } - w.Reindex() - - // Background tile (RGBA) — typical texture size. - bg := image.NewRGBA(image.Rect(0, 0, 96, 96)) - // Make it semi-transparent so draw.Over has real work. - for y := 0; y < 96; y++ { - for x := 0; x < 96; x++ { - bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18}) - } - } - - w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale}) - - dc := gg.NewContext(1200, 800) - drawer := &GGDrawer{DC: dc} - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: &IncrementalPolicy{ - AllowShiftOnly: false, - CoalesceUpdates: false, - MaxCatchUpAreaPx: 0, - RenderBudgetMs: 0, - }, - }, - } - - _ = w.Render(drawer, params) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - params.CameraXWorldFp += 1 * SCALE - _ = w.Render(drawer, params) - } -} - -// BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG. -func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) { - w := NewWorld(600, 600) - w.IndexOnViewportChange(1000, 700, 1.0) - - // Make a lot of lines, including ones that likely wrap. - for i := 0; i < 4000; i++ { - x1 := float64(i % 600) - y1 := float64((i * 7) % 600) - x2 := float64((i*13 + 500) % 600) // shift to create various deltas - y2 := float64((i*17 + 300) % 600) - _, _ = w.AddLine(x1, y1, x2, y2) - } - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - BackgroundColor: color.RGBA{A: 255}, - }, - } - - plan, err := w.buildRenderPlan(params) - if err != nil { - b.Fatalf("build plan: %v", err) - } - - dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx()) - drawer := &GGDrawer{DC: dc} - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) - } -} - -// BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake. -func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) { - w := NewWorld(600, 600) - w.IndexOnViewportChange(1000, 700, 1.0) - - for i := 0; i < 4000; i++ { - x1 := float64(i % 600) - y1 := float64((i * 7) % 600) - x2 := float64((i*13 + 500) % 600) - y2 := float64((i*17 + 300) % 600) - _, _ = w.AddLine(x1, y1, x2, y2) - } - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - BackgroundColor: color.RGBA{A: 255}, - }, - } - - plan, err := w.buildRenderPlan(params) - if err != nil { - b.Fatalf("build plan: %v", err) - } - - drawer := &fakePrimitiveDrawer{} - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - // Reset command log so it doesn't grow forever and dominate allocations. - drawer.Reset() - w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) - } -} - -// TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips. -func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.IndexOnViewportChange(100, 80, 1.0) - w.resetGrid(2 * SCALE) - - _, _ = w.AddPoint(5, 5) - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: &IncrementalPolicy{AllowShiftOnly: false}, - }, - } - - // First render initializes state. - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, params)) - - // Small pan. - params2 := params - params2.CameraXWorldFp += 1 * SCALE - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params2)) - - // Expect very few ClipRect calls (dirty strips count), not per tile. - clipCmds := d2.CommandsByName("ClipRect") - require.NotEmpty(t, clipCmds) - require.LessOrEqual(t, len(clipCmds), 4) -} - -// TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID. -func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.IndexOnViewportChange(100, 80, 1.0) - - // Two lines with default style, same priority. - _, _ = w.AddLine(1, 1, 8, 1) - _, _ = w.AddLine(1, 2, 8, 2) - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - // We expect at least two AddLine, but only 1 Stroke for that run in a tile. - adds := d.CommandsByName("AddLine") - strokes := d.CommandsByName("Stroke") - require.GreaterOrEqual(t, len(adds), 2) - require.GreaterOrEqual(t, len(strokes), 1) - - // Stronger: within any consecutive group of AddLine commands, count strokes <= 1. - // (Keep it loose to avoid depending on tile partitioning.) -} - -// BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse. -func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) { - w := NewWorld(600, 600) - - // Make grid + index available. - w.IndexOnViewportChange(1000, 700, 1.0) - - // Add enough objects so tiles have candidates. - for i := range 2000 { - _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) - } - for i := range 500 { - _, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0) - } - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - BackgroundColor: color.RGBA{A: 255}, - }, - } - - plan, err := w.buildRenderPlan(params) - if err != nil { - b.Fatalf("build plan: %v", err) - } - - dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx()) - drawer := &GGDrawer{DC: dc} - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - // We don't clear here; we only measure the draw loop overhead. - w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false) - } -} - -// BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates. -func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) { - w := NewWorld(600, 600) - - // Make the index/grid available. - w.IndexOnViewportChange(1000, 700, 1.0) - - // Populate with enough objects to create duplicates across cells. - // Circles and lines create bbox indexing (more duplicates). - for i := 0; i < 2000; i++ { - _, _ = w.AddPoint(float64(i%600), float64((i*7)%600)) - } - for i := 0; i < 1200; i++ { - _, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0) - } - for i := 0; i < 1200; i++ { - x1 := float64((i*3 + 10) % 600) - y1 := float64((i*5 + 20) % 600) - x2 := float64((i*7 + 400) % 600) - y2 := float64((i*11 + 300) % 600) - _, _ = w.AddLine(x1, y1, x2, y2) - } - w.Reindex() - - params := RenderParams{ - ViewportWidthPx: 1000, - ViewportHeightPx: 700, - MarginXPx: 250, - MarginYPx: 175, - CameraXWorldFp: 300 * SCALE, - CameraYWorldFp: 300 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - BackgroundColor: color.RGBA{A: 255}, - }, - } - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - _, err := w.buildRenderPlan(params) - if err != nil { - b.Fatalf("build plan: %v", err) - } - } -} diff --git a/client/world/renderer_test.go b/client/world/renderer_test.go deleted file mode 100644 index 58345ff..0000000 --- a/client/world/renderer_test.go +++ /dev/null @@ -1,3325 +0,0 @@ -package world - -import ( - "github.com/stretchr/testify/require" - "image" - "image/color" - "sort" - "testing" -) - -// TestRenderParamsCanvasSize verifies render Params Canvas Size. -func TestRenderParamsCanvasSize(t *testing.T) { - t.Parallel() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - } - - require.Equal(t, 150, params.CanvasWidthPx()) - require.Equal(t, 120, params.CanvasHeightPx()) -} - -// TestRenderParamsCameraZoomFp verifies render Params Camera Zoom Fp. -func TestRenderParamsCameraZoomFp(t *testing.T) { - t.Parallel() - - params := RenderParams{ - CameraZoom: 1.25, - } - - zoomFp, err := params.CameraZoomFp() - require.NoError(t, err) - require.Equal(t, 1250, zoomFp) -} - -// TestRenderParamsExpandedCanvasWorldRect verifies render Params Expanded Canvas World Rect. -func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) { - t.Parallel() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 50 * SCALE, - CameraYWorldFp: 70 * SCALE, - CameraZoom: 2.0, - } - - rect, err := params.ExpandedCanvasWorldRect() - require.NoError(t, err) - - require.Equal(t, 12500, rect.minX) - require.Equal(t, 87500, rect.maxX) - require.Equal(t, 40000, rect.minY) - require.Equal(t, 100000, rect.maxY) -} - -// TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera verifies render Params Expanded Canvas World Rect Allows Out Of World Camera. -func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) { - t.Parallel() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: -10 * SCALE, - CameraYWorldFp: 3 * SCALE, - CameraZoom: 1.0, - } - - rect, err := params.ExpandedCanvasWorldRect() - require.NoError(t, err) - - require.Equal(t, -85000, rect.minX) - require.Equal(t, 65000, rect.maxX) - require.Equal(t, -57000, rect.minY) - require.Equal(t, 63000, rect.maxY) -} - -// TestRenderParamsValidate verifies render Params Validate. -func TestRenderParamsValidate(t *testing.T) { - t.Parallel() - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 123456, - CameraYWorldFp: -987654, - CameraZoom: 1.0, - } - - require.NoError(t, params.Validate()) -} - -// TestRenderParamsValidateRejectsInvalidViewport verifies render Params Validate Rejects Invalid Viewport. -func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) { - t.Parallel() - - tests := []RenderParams{ - { - ViewportWidthPx: 0, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }, - { - ViewportWidthPx: 100, - ViewportHeightPx: 0, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }, - { - ViewportWidthPx: -1, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }, - { - ViewportWidthPx: 100, - ViewportHeightPx: -1, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }, - } - - for _, params := range tests { - require.ErrorIs(t, params.Validate(), errInvalidViewportSize) - } -} - -// TestRenderParamsValidateRejectsInvalidMargins verifies render Params Validate Rejects Invalid Margins. -func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) { - t.Parallel() - - tests := []RenderParams{ - { - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: -1, - MarginYPx: 20, - CameraZoom: 1.0, - }, - { - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: -1, - CameraZoom: 1.0, - }, - } - - for _, params := range tests { - require.ErrorIs(t, params.Validate(), errInvalidMargins) - } -} - -// TestRenderParamsValidateRejectsInvalidCameraZoom verifies render Params Validate Rejects Invalid Camera Zoom. -func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) { - t.Parallel() - - tests := []RenderParams{ - { - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 0, - }, - { - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: -1, - }, - { - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 0.0004, - }, - } - - for _, params := range tests { - require.EqualError(t, params.Validate(), "invalid camera zoom") - } -} - -// TestExpandedCanvasWorldRect verifies expanded Canvas World Rect. -func TestExpandedCanvasWorldRect(t *testing.T) { - t.Parallel() - - rect := expandedCanvasWorldRect( - 50*SCALE, 70*SCALE, - 150, 120, - 2*SCALE, - ) - - require.Equal(t, 12500, rect.minX) - require.Equal(t, 87500, rect.maxX) - require.Equal(t, 40000, rect.minY) - require.Equal(t, 100000, rect.maxY) -} - -// TestExpandedCanvasWorldRectPanics verifies expanded Canvas World Rect Panics. -func TestExpandedCanvasWorldRectPanics(t *testing.T) { - t.Parallel() - - require.Panics(t, func() { - _ = expandedCanvasWorldRect(0, 0, 0, 10, SCALE) - }) - - require.Panics(t, func() { - _ = expandedCanvasWorldRect(0, 0, 10, 0, SCALE) - }) - - require.Panics(t, func() { - _ = expandedCanvasWorldRect(0, 0, 10, 10, 0) - }) -} - -// TestWorldRenderRejectsNilDrawer verifies world Render Rejects Nil Drawer. -func TestWorldRenderRejectsNilDrawer(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - err := w.Render(nil, RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }) - - require.ErrorIs(t, err, errNilDrawer) -} - -// TestWorldRenderRejectsInvalidParams verifies world Render Rejects Invalid Params. -func TestWorldRenderRejectsInvalidParams(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ - ViewportWidthPx: 0, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraZoom: 1.0, - }) - - require.ErrorIs(t, err, errInvalidViewportSize) -} - -// TestWorldRenderReturnsErrorWhenGridNotBuilt verifies world Render Returns Error When Grid Not Built. -func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - }) - - require.ErrorIs(t, err, errGridNotBuilt) -} - -// TestWorldRenderStageAStubReturnsNilOnValidInput verifies world Render Stage A Stub Returns Nil On Valid Input. -func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - // Render relies on the spatial grid being built. - // In production UI this is done via IndexOnViewportChange. - w.IndexOnViewportChange(100, 80, 1.0) - - err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 12345, - CameraYWorldFp: -67890, - CameraZoom: 1.0, - }) - - require.NoError(t, err) -} - -// TestTileWorldRect_NoWrapSingleTile verifies tile World Rect No Wrap Single Tile. -func TestTileWorldRect_NoWrapSingleTile(t *testing.T) { - t.Parallel() - - worldW := 100 - worldH := 80 - - rect := Rect{minX: 10, maxX: 30, minY: 5, maxY: 25} - tiles := tileWorldRect(rect, worldW, worldH) - - require.Len(t, tiles, 1) - require.Equal(t, 0, tiles[0].OffsetX) - require.Equal(t, 0, tiles[0].OffsetY) - - require.Equal(t, 10, tiles[0].Rect.minX) - require.Equal(t, 30, tiles[0].Rect.maxX) - require.Equal(t, 5, tiles[0].Rect.minY) - require.Equal(t, 25, tiles[0].Rect.maxY) -} - -// TestTileWorldRect_WrapX_TwoTiles verifies tile World Rect Wrap X Two Tiles. -func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) { - t.Parallel() - - worldW := 100 - worldH := 80 - - // Crosses the X boundary once: [30..130) maps to: - // tile 0: [30..100) offset 0 - // tile 1: [0..30) offset 100 - rect := Rect{minX: 30, maxX: 130, minY: 10, maxY: 20} - tiles := tileWorldRect(rect, worldW, worldH) - - require.Len(t, tiles, 2) - - require.Equal(t, 0, tiles[0].OffsetX) - require.Equal(t, 0, tiles[0].OffsetY) - require.Equal(t, Rect{minX: 30, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) - - require.Equal(t, 100, tiles[1].OffsetX) - require.Equal(t, 0, tiles[1].OffsetY) - require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect) -} - -// TestTileWorldRect_WrapX_NegativeCoords verifies tile World Rect Wrap X Negative Coords. -func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) { - t.Parallel() - - worldW := 100 - worldH := 80 - - // Crosses boundary around 0: [-20..20) maps to: - // tile -1: [80..100) offset -100 - // tile 0: [0..20) offset 0 - rect := Rect{minX: -20, maxX: 20, minY: 10, maxY: 20} - tiles := tileWorldRect(rect, worldW, worldH) - - require.Len(t, tiles, 2) - - require.Equal(t, -100, tiles[0].OffsetX) - require.Equal(t, 0, tiles[0].OffsetY) - require.Equal(t, Rect{minX: 80, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) - - require.Equal(t, 0, tiles[1].OffsetX) - require.Equal(t, 0, tiles[1].OffsetY) - require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect) -} - -// TestTileWorldRect_WrapXY_FourTiles verifies tile World Rect Wrap XY Four Tiles. -func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) { - t.Parallel() - - worldW := 100 - worldH := 80 - - // Crosses both X and Y boundaries once. - // X: [30..130) => tile 0 [30..100), tile 1 [0..30) - // Y: [60..100) => tile 0 [60..80), tile 1 [0..20) - rect := Rect{minX: 30, maxX: 130, minY: 60, maxY: 100} - tiles := tileWorldRect(rect, worldW, worldH) - - require.Len(t, tiles, 4) - - // Order: tx ascending, then ty ascending. - require.Equal(t, 0, tiles[0].OffsetX) - require.Equal(t, 0, tiles[0].OffsetY) - require.Equal(t, Rect{minX: 30, maxX: 100, minY: 60, maxY: 80}, tiles[0].Rect) - - require.Equal(t, 0, tiles[1].OffsetX) - require.Equal(t, 80, tiles[1].OffsetY) - require.Equal(t, Rect{minX: 30, maxX: 100, minY: 0, maxY: 20}, tiles[1].Rect) - - require.Equal(t, 100, tiles[2].OffsetX) - require.Equal(t, 0, tiles[2].OffsetY) - require.Equal(t, Rect{minX: 0, maxX: 30, minY: 60, maxY: 80}, tiles[2].Rect) - - require.Equal(t, 100, tiles[3].OffsetX) - require.Equal(t, 80, tiles[3].OffsetY) - require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect) -} - -// TestTileWorldRect_EmptyRectReturnsNil verifies tile World Rect Empty Rect Returns Nil. -func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) { - t.Parallel() - - worldW := 100 - worldH := 80 - - require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) - require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 10, minY: 0, maxY: 0}, worldW, worldH)) - require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) -} - -// TestTileWorldRectPanicsOnInvalidWorldSize verifies tile World Rect Panics On Invalid World Size. -func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) { - t.Parallel() - - require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 0, 10) }) - require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) }) -} - -// TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt verifies collect Candidates For Tiles Returns Error When Grid Not Built. -func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - tiles := []WorldTile{ - { - Rect: Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}, - }, - } - - _, err := w.collectCandidatesForTiles(tiles) - require.ErrorIs(t, err, errGridNotBuilt) -} - -// TestCollectCandidatesForTileDedupsWithinOneTile verifies collect Candidates For Tile Dedups Within One Tile. -func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) // 5x5 grid - - // Circle in the middle, radius big enough to cover multiple cells. - id, err := w.AddCircle(5, 5, 2.2) - require.NoError(t, err) - - // Build index. - w.indexObject(w.objects[id]) - - // Query whole world tile. - items := w.collectCandidatesForTile(Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}) - - // The circle is indexed into multiple cells, but must appear only once in candidates. - require.Len(t, items, 1) - require.Equal(t, id, items[0].ID()) -} - -// TestCollectCandidatesForTileReturnsPointInCoveredCell verifies collect Candidates For Tile Returns Point In Covered Cell. -func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) // cells are [0..2), [2..4), ... - - id, err := w.AddPoint(1.0, 1.0) // (1000,1000) => cell (0,0) - require.NoError(t, err) - - w.indexObject(w.objects[id]) - - // Query exactly the first cell as half-open rect. - r := Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: 2 * SCALE} - items := w.collectCandidatesForTile(r) - - require.Len(t, items, 1) - require.Equal(t, id, items[0].ID()) - - // Query adjacent cell (should not contain the point). - r2 := Rect{minX: 2 * SCALE, maxX: 4 * SCALE, minY: 0, maxY: 2 * SCALE} - items2 := w.collectCandidatesForTile(r2) - require.Empty(t, items2) -} - -// TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides verifies collect Candidates For Tiles Wrap Indexed Circle Appears In Both Sides. -func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetCircleRadiusScaleFp(SCALE) - w.resetGrid(2 * SCALE) - - // Circle near the left edge crossing X=0 boundary. - // With radius 1.0 it covers [-0.5..1.5] in world units => wrap indexes both left and right sides. - id, err := w.AddCircle(0.5, 5.0, 1.0) - require.NoError(t, err) - w.indexObject(w.objects[id]) - - leftStrip := WorldTile{ - Rect: Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: w.H}, - } - rightStrip := WorldTile{ - Rect: Rect{minX: w.W - 2*SCALE, maxX: w.W, minY: 0, maxY: w.H}, - } - - batches, err := w.collectCandidatesForTiles([]WorldTile{leftStrip, rightStrip}) - require.NoError(t, err) - require.Len(t, batches, 2) - - // Expect the circle candidate to appear in both batches (different render offsets later). - require.Len(t, batches[0].Items, 1) - require.Equal(t, id, batches[0].Items[0].ID()) - - require.Len(t, batches[1].Items, 1) - require.Equal(t, id, batches[1].Items[0].ID()) -} - -// TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas verifies build Render Plan Stage A Single Tile Clip Is Whole Canvas. -func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { - t.Parallel() - - // World: 100x80 real => 100000x80000 fixed. - w := NewWorld(100, 80) - - // Build any grid (cell size doesn't matter for this test, but grid must exist). - w.resetGrid(10 * SCALE) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 50 * SCALE, - CameraYWorldFp: 40 * SCALE, - CameraZoom: 2.0, - } - - plan, err := w.buildRenderPlan(params) - require.NoError(t, err) - - require.Equal(t, 150, plan.CanvasWidthPx) - require.Equal(t, 120, plan.CanvasHeightPx) - require.Equal(t, 2*SCALE, plan.ZoomFp) - - require.Len(t, plan.Tiles, 1) - td := plan.Tiles[0] - - require.Equal(t, 0, td.ClipX) - require.Equal(t, 0, td.ClipY) - require.Equal(t, 150, td.ClipW) - require.Equal(t, 120, td.ClipH) - - require.Equal(t, 0, td.Tile.OffsetX) - require.Equal(t, 0, td.Tile.OffsetY) -} - -// TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps verifies build Render Plan Stage A Tiles Cover Canvas Without Gaps. -func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { - t.Parallel() - - // World: 10x10 => 10000x10000 fixed. - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Use zoom=1 and the same canvas geometry as earlier: 150x120. - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlan(params) - require.NoError(t, err) - - require.Equal(t, 150, plan.CanvasWidthPx) - require.Equal(t, 120, plan.CanvasHeightPx) - require.Equal(t, SCALE, plan.ZoomFp) - - type interval struct { - start int - end int - } - - // Full tile size in pixels for one whole world width/height at the current zoom. - fullTileXPx := worldSpanFixedToCanvasPx(w.W, plan.ZoomFp) - fullTileYPx := worldSpanFixedToCanvasPx(w.H, plan.ZoomFp) - require.Greater(t, fullTileXPx, 0) - require.Greater(t, fullTileYPx, 0) - - // ---- X coverage ---- - // Take the first Y strip only to avoid duplicates across Y. - intervalsX := make([]interval, 0, len(plan.Tiles)) - for _, td := range plan.Tiles { - if td.ClipY == 0 && td.ClipH > 0 && td.ClipW > 0 { - intervalsX = append(intervalsX, interval{ - start: td.ClipX, - end: td.ClipX + td.ClipW, - }) - // A single tile must never exceed one whole world-tile width in pixels. - require.LessOrEqual(t, td.ClipW, fullTileXPx, "tile width must not exceed one world tile in pixels") - } - } - require.NotEmpty(t, intervalsX) - - sort.Slice(intervalsX, func(i, j int) bool { - if intervalsX[i].start != intervalsX[j].start { - return intervalsX[i].start < intervalsX[j].start - } - return intervalsX[i].end < intervalsX[j].end - }) - - require.Equal(t, 0, intervalsX[0].start) - cursorX := intervalsX[0].end - for i := 1; i < len(intervalsX); i++ { - require.Equal(t, cursorX, intervalsX[i].start, "gap/overlap in X coverage between intervals %d and %d", i-1, i) - cursorX = intervalsX[i].end - } - require.Equal(t, plan.CanvasWidthPx, cursorX) - - // ---- Y coverage ---- - // Take the first X strip only to avoid duplicates across X. - intervalsY := make([]interval, 0, len(plan.Tiles)) - for _, td := range plan.Tiles { - if td.ClipX == 0 && td.ClipW > 0 && td.ClipH > 0 { - intervalsY = append(intervalsY, interval{ - start: td.ClipY, - end: td.ClipY + td.ClipH, - }) - // A single tile must never exceed one whole world-tile height in pixels. - require.LessOrEqual(t, td.ClipH, fullTileYPx, "tile height must not exceed one world tile in pixels") - } - } - require.NotEmpty(t, intervalsY) - - sort.Slice(intervalsY, func(i, j int) bool { - if intervalsY[i].start != intervalsY[j].start { - return intervalsY[i].start < intervalsY[j].start - } - return intervalsY[i].end < intervalsY[j].end - }) - - require.Equal(t, 0, intervalsY[0].start) - cursorY := intervalsY[0].end - for i := 1; i < len(intervalsY); i++ { - require.Equal(t, cursorY, intervalsY[i].start, "gap/overlap in Y coverage between intervals %d and %d", i-1, i) - cursorY = intervalsY[i].end - } - require.Equal(t, plan.CanvasHeightPx, cursorY) -} - -// TestBuildRenderPlanStageA_CandidatesArePerTileDeduped verifies build Render Plan Stage A Candidates Are Per Tile Deduped. -func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Put one circle in the world and index it, it will occupy multiple cells. - id, err := w.AddCircle(5, 5, 2.2) - require.NoError(t, err) - w.indexObject(w.objects[id]) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlan(params) - require.NoError(t, err) - require.NotEmpty(t, plan.Tiles) - - // Find any tile that has candidates; expect the circle to appear at most once per tile. - for _, td := range plan.Tiles { - if len(td.Candidates) == 0 { - continue - } - seen := map[PrimitiveID]struct{}{} - for _, it := range td.Candidates { - _, ok := seen[it.ID()] - require.False(t, ok, "candidate duplicated within a tile") - seen[it.ID()] = struct{}{} - } - } -} - -// TestWorldForceFullRedrawNextResetsIncrementalState verifies world Force Full Redraw Next Resets Incremental State. -func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - // Initialize state via a full render commit. - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - w.resetGrid(2 * SCALE) - require.NoError(t, w.CommitFullRedrawState(params)) - require.True(t, w.renderState.initialized) - - w.ForceFullRedrawNext() - require.False(t, w.renderState.initialized) -} - -// TestRender_SortsByPriorityWithinTile verifies render Sorts By Priority Within Tile. -func TestRender_SortsByPriorityWithinTile(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Same tile. Priorities deliberately mixed. - _, err := w.AddCircle(5, 5, 1, CircleWithPriority(500)) - require.NoError(t, err) - - _, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100)) - require.NoError(t, err) - - _, err = w.AddPoint(5, 6, PointWithPriority(300)) - 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{ - // default: wrap on - }, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - // We verify the first occurrence of each primitive kind follows priority order. - // Since each object is drawn with Add* + Fill/Stroke immediately, order should match. - cmds := d.Commands() - firstLine := indexOfFirst(cmds, "AddLine") - firstCircle := indexOfFirst(cmds, "AddCircle") - firstPoint := indexOfFirst(cmds, "AddPoint") - - require.NotEqual(t, -1, firstLine) - require.NotEqual(t, -1, firstCircle) - require.NotEqual(t, -1, firstPoint) - - require.Less(t, firstLine, firstPoint) - require.Less(t, firstPoint, firstCircle) // 300 before 500 -} - -func indexOfFirst(cmds []fakeDrawerCommand, name string) int { - for i, c := range cmds { - if c.Name == name { - return i - } - } - return -1 -} - -type bgOffsetScaleTheme struct { - img image.Image - anchor BackgroundAnchorMode -} - -func (t bgOffsetScaleTheme) ID() string { return "bgoffset" } -func (t bgOffsetScaleTheme) Name() string { return "bgoffset" } - -func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img } - -func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } -func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } - -func (t bgOffsetScaleTheme) PointStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} -} -func (t bgOffsetScaleTheme) LineStyle() Style { - return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (t bgOffsetScaleTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan verifies render Background Tile Repeat World Anchored Shifts With Pan. -func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) { - t.Parallel() - - w := NewWorld(20, 20) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4 - w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld}) - - params := RenderParams{ - ViewportWidthPx: 8, - ViewportHeightPx: 8, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - // First render. - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, params)) - - minX1, minY1 := minDrawImageXY(t, d1) - require.Equal(t, -1, minX1) - require.Equal(t, -1, minY1) - - // Pan camera by +1 world unit along both axes (zoom=1 => 1px). - params2 := params - params2.CameraXWorldFp += 1 * SCALE - params2.CameraYWorldFp += 1 * SCALE - - // Force full redraw to make this test independent of incremental pipeline. - w.ForceFullRedrawNext() - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params2)) - - minX2, minY2 := minDrawImageXY(t, d2) - - // With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size). - require.Equal(t, -2, minX2) - require.Equal(t, -2, minY2) -} - -// TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan verifies render Background Tile Repeat Viewport Anchored Does Not Shift With Pan. -func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) { - t.Parallel() - - w := NewWorld(20, 20) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport}) - - params := RenderParams{ - ViewportWidthPx: 8, - ViewportHeightPx: 8, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, params)) - - minX1, minY1 := minDrawImageXY(t, d1) - - params2 := params - params2.CameraXWorldFp += 1 * SCALE - params2.CameraYWorldFp += 1 * SCALE - - w.ForceFullRedrawNext() - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params2)) - - minX2, minY2 := minDrawImageXY(t, d2) - - // With viewport anchoring, tiling origin is fixed (no camera dependency). - require.Equal(t, minX1, minX2) - require.Equal(t, minY1, minY2) -} - -func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) { - t.Helper() - - cmds := d.CommandsByName("DrawImage") - require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling") - - minX := int(cmds[0].Args[0]) - minY := int(cmds[0].Args[1]) - - for _, c := range cmds[1:] { - x := int(c.Args[0]) - y := int(c.Args[1]) - if x < minX { - minX = x - } - if y < minY { - minY = y - } - } - return minX, minY -} - -type bgOffsetTheme struct { - img image.Image - scaleMode BackgroundScaleMode -} - -func (t bgOffsetTheme) ID() string { return "bgscale" } -func (t bgOffsetTheme) Name() string { return "bgscale" } - -func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img } - -func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } -func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } - -func (t bgOffsetTheme) PointStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} -} -func (t bgOffsetTheme) LineStyle() Style { - return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (t bgOffsetTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestRender_BackgroundScaleNone_UsesOffsetDrawImage verifies render Background Scale None Uses Offset Draw Image. -func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone}) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - require.NotEmpty(t, d.CommandsByName("DrawImage")) - require.Empty(t, d.CommandsByName("DrawImageScaled")) -} - -// TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled verifies render Background Scale Fit Uses Draw Offset Image Scaled. -func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit}) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) -} - -type bgScaleTheme struct { - img image.Image - scaleMode BackgroundScaleMode -} - -func (t bgScaleTheme) ID() string { return "bgscale" } -func (t bgScaleTheme) Name() string { return "bgscale" } - -func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t bgScaleTheme) BackgroundImage() image.Image { return t.img } - -func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } -func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } - -func (t bgScaleTheme) PointStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} -} -func (t bgScaleTheme) LineStyle() Style { - return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (t bgScaleTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestRender_BackgroundScaleNone_UsesDrawImage verifies render Background Scale None Uses Draw Image. -func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone}) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - require.NotEmpty(t, d.CommandsByName("DrawImage")) - require.Empty(t, d.CommandsByName("DrawImageScaled")) -} - -// TestRender_BackgroundScaleFit_UsesDrawImageScaled verifies render Background Scale Fit Uses Draw Image Scaled. -func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit}) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) -} - -type bgTheme struct { - img image.Image -} - -func (t bgTheme) ID() string { return "bg" } -func (t bgTheme) Name() string { return "bg" } - -func (t bgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t bgTheme) BackgroundImage() image.Image { return t.img } - -func (t bgTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } -func (t bgTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (t bgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } - -func (t bgTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } -func (t bgTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } -func (t bgTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t bgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t bgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (t bgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw verifies render Background Image Draws Before Primitives Full Redraw. -func TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // 4x4 opaque image - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgTheme{img: img}) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - cmds := d.Commands() - iClear := indexOfFirstName(cmds, "ClearAllTo") - iBg := indexOfFirstName(cmds, "DrawImage") - iPrim := indexOfFirstName(cmds, "AddPoint") - - require.NotEqual(t, -1, iClear) - require.NotEqual(t, -1, iBg) - require.NotEqual(t, -1, iPrim) - require.Less(t, iClear, iBg) - require.Less(t, iBg, iPrim) -} - -// TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift verifies render Background Image Redrawn In Dirty Rects On Incremental Shift. -func TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - img := image.NewRGBA(image.Rect(0, 0, 4, 4)) - w.SetTheme(bgTheme{img: img}) - - // Ensure state: first full render commits. - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: &IncrementalPolicy{ - AllowShiftOnly: false, - MaxCatchUpAreaPx: 0, - RenderBudgetMs: 0, - CoalesceUpdates: false, - }, - }, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - // Move camera by 1px right in world (zoom=1 => 1px == 1 unit). - params2 := params - params2.CameraXWorldFp += 1 * SCALE - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params2)) - - // In incremental shift path we must see ClearRectTo and DrawImage. - require.NotEmpty(t, d2.CommandsByName("CopyShift")) - require.NotEmpty(t, d2.CommandsByName("ClearRectTo")) - require.NotEmpty(t, d2.CommandsByName("DrawImage")) -} - -// TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips verifies draw Circles From Plan Duplicates Across Tiles And Clips. -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.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) - - // 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) - } -} - -// TestDrawCirclesFromPlan_SkipsTilesWithoutCircles verifies draw Circles From Plan Skips Tiles Without Circles. -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.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) - - // No circles => no commands. - require.Empty(t, d.Commands()) -} - -// TestDrawCirclesFromPlan_ProjectsRadiusWithZoom verifies draw Circles From Plan Projects Radius With Zoom. -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.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) - - // 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]) - } -} - -// TestCircles_NoWrap_DoesNotDuplicateAcrossEdges verifies circles No Wrap Does Not Duplicate Across Edges. -func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetCircleRadiusScaleFp(SCALE) - 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) -} - -// TestRender_CircleTransparentFill_UsesStrokeNotFill verifies render Circle Transparent Fill Uses Stroke Not Fill. -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") -} - -// TestRender_CircleFillAndStroke_DrawsFillThenStroke verifies render Circle Fill And Stroke Draws Fill Then Stroke. -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") -} - -// TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies circles Wrap Copies Appear Inside Viewport When Viewport Equals World. -func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { - t.Parallel() - - // World 10x10 units => 10px at zoom=1 when viewport==world. - w := NewWorld(10, 10) - w.SetCircleRadiusScaleFp(SCALE) - w.resetGrid(2 * SCALE) - - type tc struct { - name string - x, y float64 - r float64 - wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0 - } - - // Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0. - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - tests := []tc{ - { - name: "bottom boundary wraps to top", - x: 5, y: 9, r: 2, - // Centers: original at y=9, copy at y=-1. - wantCenters: [][2]float64{{5, 9}, {5, -1}}, - }, - { - name: "right boundary wraps to left", - x: 9, y: 5, r: 2, - wantCenters: [][2]float64{{9, 5}, {-1, 5}}, - }, - { - name: "corner wraps to three extra copies", - x: 9, y: 9, r: 2, - wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}}, - }, - { - name: "no wrap inside", - x: 5, y: 5, r: 2, - wantCenters: [][2]float64{{5, 5}}, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w2 := NewWorld(10, 10) - w2.resetGrid(2 * SCALE) - - _, err := w2.AddCircle(tt.x, tt.y, tt.r) - require.NoError(t, err) - - for _, obj := range w2.objects { - w2.indexObject(obj) - } - - plan, err := w2.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp) - - cmds := d.CommandsByName("AddCircle") - require.Len(t, cmds, len(tt.wantCenters)) - - // Collect centers (ignore radius for this test). - got := make([][2]float64, 0, len(cmds)) - for _, c := range cmds { - require.Len(t, c.Args, 3) - got = append(got, [2]float64{c.Args[0], c.Args[1]}) - } - - // Order is deterministic with our shift generation and tile iteration for margin=0: single tile. - require.ElementsMatch(t, tt.wantCenters, got) - }) - } -} - -// TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop verifies render Shift Only Over Budget Defers Dirty And Catches Up On Stop. -func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 4, // threshold=2 - MarginYPx: 4, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: &IncrementalPolicy{ - AllowShiftOnly: true, - RenderBudgetMs: 1, // 1ms budget - }, - }, - } - - // First render (full) initializes state. - d0 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d0, params)) - require.True(t, w.renderState.initialized) - - // Pretend previous render was very slow => over budget for the next frame. - w.renderState.lastRenderDurationNs = 10_000_000 // 10ms - - // Pan right by 1 unit => incremental shift candidate. - params2 := params - params2.CameraXWorldFp += 1 * SCALE - - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, params2)) - - // Shift-only should call CopyShift but not redraw dirty rects. - require.NotEmpty(t, d1.CommandsByName("CopyShift")) - require.Empty(t, d1.CommandsByName("ClipRect")) - require.NotEmpty(t, w.renderState.pendingDirty) - - // Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty. - w.renderState.lastRenderDurationNs = 0 // under budget - - params3 := params2 // same camera - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params3)) - - require.NotEmpty(t, d2.CommandsByName("ClipRect")) - require.NotEmpty(t, d2.CommandsByName("AddPoint")) - require.Empty(t, w.renderState.pendingDirty) -} - -// TestRender_CatchUpWhilePanning_WhenBackUnderBudget verifies render Catch Up While Panning When Back Under Budget. -func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - for _, obj := range w.objects { - w.indexObject(obj) - } - - policy := &IncrementalPolicy{ - AllowShiftOnly: true, - RenderBudgetMs: 1, - } - - base := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 4, // threshold=2 - MarginYPx: 4, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: policy, - }, - } - - // Initial full render. - require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) - - // Frame 1: over budget => shift-only, pendingDirty accumulates. - w.renderState.lastRenderDurationNs = 10_000_000 // 10ms - - p1 := base - p1.CameraXWorldFp += 1 * SCALE - - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, p1)) - - require.NotEmpty(t, d1.CommandsByName("CopyShift")) - require.Empty(t, d1.CommandsByName("ClipRect")) - require.NotEmpty(t, w.renderState.pendingDirty) - - // Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty). - w.renderState.lastRenderDurationNs = 0 - - p2 := p1 - p2.CameraXWorldFp += 1 * SCALE - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, p2)) - - require.NotEmpty(t, d2.CommandsByName("CopyShift")) - require.NotEmpty(t, d2.CommandsByName("ClipRect")) - require.NotEmpty(t, d2.CommandsByName("AddPoint")) - require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw") -} - -// TestRender_CatchUpLimit_ReducesPendingDirtyGradually verifies render Catch Up Limit Reduces Pending Dirty Gradually. -func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - for _, obj := range w.objects { - w.indexObject(obj) - } - - policy := &IncrementalPolicy{ - AllowShiftOnly: true, - RenderBudgetMs: 1, - MaxCatchUpAreaPx: 20, // very small budget - } - - base := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 2, - MarginXPx: 4, - MarginYPx: 4, // canvasH = 2 + 8 = 10 - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Incremental: policy, - }, - } - - // Full init - require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) - - // Over budget => shift-only twice to accumulate pending dirty. - w.renderState.lastRenderDurationNs = 10_000_000 - - p1 := base - p1.CameraXWorldFp += 1 * SCALE - require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1)) - require.NotEmpty(t, w.renderState.pendingDirty) - - w.renderState.lastRenderDurationNs = 10_000_000 - p2 := p1 - p2.CameraXWorldFp += 1 * SCALE - require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) - require.NotEmpty(t, w.renderState.pendingDirty) - - // Under budget now, but limit catch-up. - w.renderState.lastRenderDurationNs = 0 - - before := len(w.renderState.pendingDirty) - require.Greater(t, before, 0) - - require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) - after := len(w.renderState.pendingDirty) - - // With a tiny MaxCatchUpAreaPx we should not clear everything in one go. - require.Greater(t, after, 0) - require.Less(t, after, before) -} - -// TestTakeCatchUpRects_RespectsAreaLimit verifies take Catch Up Rects Respects Area Limit. -func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) { - t.Parallel() - - pending := []RectPx{ - {X: 0, Y: 0, W: 10, H: 1}, // area 10 - {X: 0, Y: 1, W: 10, H: 2}, // area 20 - {X: 0, Y: 3, W: 10, H: 3}, // area 30 - } - - // Limit 25 => should take first (10) + second (20) would exceed => take only first. - sel, rem := takeCatchUpRects(pending, 25) - require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel) - require.Equal(t, []RectPx{ - {X: 0, Y: 1, W: 10, H: 2}, - {X: 0, Y: 3, W: 10, H: 3}, - }, rem) - - // Limit 30 => can take first(10) + second(20) exactly. - sel, rem = takeCatchUpRects(pending, 30) - require.Equal(t, []RectPx{ - {X: 0, Y: 0, W: 10, H: 1}, - {X: 0, Y: 1, W: 10, H: 2}, - }, sel) - require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem) - - // No limit => take all. - sel, rem = takeCatchUpRects(pending, 0) - require.Len(t, sel, 3) - require.Empty(t, rem) -} - -// TestPlanIncrementalPan_NoOp verifies plan Incremental Pan No Op. -func TestPlanIncrementalPan_NoOp(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0) - require.NoError(t, err) - require.Equal(t, IncrementalNoOp, plan.Mode) - require.Empty(t, plan.Dirty) -} - -// TestPlanIncrementalPan_FullRedrawOnInvalidCanvas verifies plan Incremental Pan Full Redraw On Invalid Canvas. -func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) { - t.Parallel() - - _, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0) - require.ErrorIs(t, err, errInvalidCanvasSize) -} - -// TestPlanIncrementalPan_FullRedrawOnTooLargeShift verifies plan Incremental Pan Full Redraw On Too Large Shift. -func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) - - plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) -} - -// TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero verifies plan Incremental Pan Full Redraw When Margin Is Zero And Delta Non Zero. -func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) - - plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) -} - -// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX verifies plan Incremental Pan Full Redraw When Exceeds Threshold X. -func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) { - t.Parallel() - - // marginX=20 => threshold=10, dx=11 => full redraw - plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) -} - -// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY verifies plan Incremental Pan Full Redraw When Exceeds Threshold Y. -func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) { - t.Parallel() - - // marginY=20 => threshold=10, dy=-11 => full redraw - plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11) - require.NoError(t, err) - require.Equal(t, IncrementalFullRedraw, plan.Mode) -} - -// TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive verifies plan Incremental Pan Shift Left Strip When Dx Positive. -func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) { - t.Parallel() - - // marginX=40 => threshold=20, dx=5 => shift ok - plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - require.Equal(t, 5, plan.DxPx) - require.Equal(t, 0, plan.DyPx) - - require.Equal(t, []RectPx{ - {X: 0, Y: 0, W: 6, H: 100}, - }, plan.Dirty) -} - -// TestPlanIncrementalPan_Shift_RightStripWhenDxNegative verifies plan Incremental Pan Shift Right Strip When Dx Negative. -func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - - require.Equal(t, []RectPx{ - {X: 200 - 8, Y: 0, W: 8, H: 100}, - }, plan.Dirty) -} - -// TestPlanIncrementalPan_Shift_TopStripWhenDyPositive verifies plan Incremental Pan Shift Top Strip When Dy Positive. -func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - - require.Equal(t, []RectPx{ - {X: 0, Y: 0, W: 200, H: 10}, - }, plan.Dirty) -} - -// TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative verifies plan Incremental Pan Shift Bottom Strip When Dy Negative. -func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - - require.Equal(t, []RectPx{ - {X: 0, Y: 100 - 10, W: 200, H: 10}, - }, plan.Dirty) -} - -// TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects verifies plan Incremental Pan Shift Diagonal Returns Two Dirty Rects. -func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - - // Overlap is allowed; we just require both strips exist. - require.Len(t, plan.Dirty, 2) - require.ElementsMatch(t, []RectPx{ - {X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip - {X: 0, Y: 0, W: 200, H: 9}, // top strip - }, plan.Dirty) -} - -// TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel verifies plan Incremental Pan Overdraws Dirty Strips By One Pixel. -func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) { - t.Parallel() - - plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) - require.NoError(t, err) - require.Equal(t, IncrementalShift, plan.Mode) - - // Right strip width should be abs(dx)+1 = 8. - require.Equal(t, []RectPx{ - {X: 200 - 8, Y: 0, W: 8, H: 100}, - }, plan.Dirty) -} - -// TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips verifies render Pan Small Uses Copy Shift And Renders Only Dirty Strips. -func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - _, err = w.AddCircle(2, 2, 1) - require.NoError(t, err) - _, err = w.AddLine(9, 5, 1, 5) - require.NoError(t, err) - - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 4, // threshold=2 - MarginYPx: 4, // threshold=2 - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - // First render initializes state (full redraw). - d0 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d0, params)) - - // Pan right by 1 unit => dx=-1 => incremental shift expected. - params2 := params - params2.CameraXWorldFp += 1 * SCALE - - d := &fakePrimitiveDrawer{} - err = w.Render(d, params2) - require.NoError(t, err) - - // Must contain CopyShift for incremental path. - require.NotEmpty(t, d.CommandsByName("CopyShift")) - - // All clip rects should be "small": width <= 1 for dx=-1 strip. - clipCmds := d.CommandsByName("ClipRect") - require.NotEmpty(t, clipCmds) - for _, c := range clipCmds { - wPx := int(c.Args[2]) - hPx := int(c.Args[3]) - require.LessOrEqual(t, wPx, 2) - require.LessOrEqual(t, hPx, params2.CanvasHeightPx()) - } - - require.NotEmpty(t, d.CommandsByName("AddPoint")) - require.NotEmpty(t, d.CommandsByName("AddCircle")) - require.NotEmpty(t, d.CommandsByName("AddLine")) -} - -// TestRender_PanTooLarge_FallsBackToFullRedraw verifies render Pan Too Large Falls Back To Full Redraw. -func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 4, // threshold=2 - MarginYPx: 4, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d0 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d0, params)) - - // Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected. - params2 := params - params2.CameraXWorldFp += 3 * SCALE - - d := &fakePrimitiveDrawer{} - err = w.Render(d, params2) - require.NoError(t, err) - - // Full redraw should NOT call CopyShift. - require.Empty(t, d.CommandsByName("CopyShift")) - - // Full redraw should clear the entire canvas. - require.NotEmpty(t, d.CommandsByName("ClearAllTo")) - - // And should draw something (at least the point). - // Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well. - require.NotEmpty(t, d.CommandsByName("AddPoint")) -} - -// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive verifies world Delta Fixed To Canvas Px Remainder Accumulates Positive. -func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) { - t.Parallel() - - // zoom=1: px = (deltaWorldFp * 1000) / 1e6 - // For deltaWorldFp=1, each step contributes 0 px with remainder, - // and after 1000 steps it must become 1 px total. - zoomFp := SCALE - var rem int64 - sum := 0 - - for i := 0; i < 1000; i++ { - sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem) - } - - require.Equal(t, 1, sum) -} - -// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative verifies world Delta Fixed To Canvas Px Remainder Accumulates Negative. -func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) { - t.Parallel() - - zoomFp := SCALE - var rem int64 - sum := 0 - - for i := 0; i < 1000; i++ { - sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem) - } - - require.Equal(t, -1, sum) -} - -// TestComputePanShiftPx_FirstCallRequiresFullRedraw verifies compute Pan Shift Px First Call Requires Full Redraw. -func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - _, _, err := w.ComputePanShiftPx(params) - require.ErrorIs(t, err, errIncrementalStateNotReady) -} - -// TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw verifies compute Pan Shift Px Zoom Or Viewport Change Forces Full Redraw. -func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - base := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - require.NoError(t, w.CommitFullRedrawState(base)) - - changed := base - changed.CameraZoom = 2.0 - - _, _, err := w.ComputePanShiftPx(changed) - require.ErrorIs(t, err, errIncrementalZoomMismatch) - - changed2 := base - changed2.ViewportWidthPx = 101 - - _, _, err = w.ComputePanShiftPx(changed2) - require.ErrorIs(t, err, errIncrementalZoomMismatch) -} - -// TestComputePanShiftPx_PanRightShiftsImageLeft verifies compute Pan Shift Px Pan Right Shifts Image Left. -func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - require.NoError(t, w.CommitFullRedrawState(params)) - - // Move camera right by 1 world unit => world rect minX increases by 1 unit, - // so content moves left by 1px at zoom=1 => image shift should be -1. - params2 := params - params2.CameraXWorldFp += 1 * SCALE - - dx, dy, err := w.ComputePanShiftPx(params2) - require.NoError(t, err) - require.Equal(t, -1, dx) - require.Equal(t, 0, dy) -} - -// TestComputePanShiftPx_PanUpShiftsImageDown verifies compute Pan Shift Px Pan Up Shifts Image Down. -func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - require.NoError(t, w.CommitFullRedrawState(params)) - - // Move camera up by 1 world unit => world rect minY decreases by 1 unit, - // so content moves down by 1px => image shift should be +1 in dy. - params2 := params - params2.CameraYWorldFp -= 1 * SCALE - - dx, dy, err := w.ComputePanShiftPx(params2) - require.NoError(t, err) - require.Equal(t, 0, dx) - require.Equal(t, 1, dy) -} - -// TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel verifies compute Pan Shift Px Sub Pixel Pan Accumulates To One Pixel. -func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 80, - MarginXPx: 25, - MarginYPx: 20, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - require.NoError(t, w.CommitFullRedrawState(params)) - - // Pan camera right by 0.001 world units (1 fixed-point) 1000 times. - // At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1. - totalDx := 0 - p := params - for i := 0; i < 1000; i++ { - p.CameraXWorldFp += 1 - dx, dy, err := w.ComputePanShiftPx(p) - require.NoError(t, err) - require.Equal(t, 0, dy) - totalDx += dx - } - - require.Equal(t, -1, totalDx) -} - -// TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits verifies torus Shortest Line Segments Tie Case Is Deterministic And Splits. -func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) { - t.Parallel() - - // World 10 units => 10000 fixed. - worldW := 10 * SCALE - worldH := 10 * SCALE - - // Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000). - // Deterministic rule chooses negative delta representation (wrap is applied). - l := Line{ - X1: 1 * SCALE, Y1: 5 * SCALE, - X2: 6 * SCALE, Y2: 5 * SCALE, - } - - segs := torusShortestLineSegments(l, worldW, worldH) - - // Expect two horizontal segments: - // [6000..10000] and [0..1000] at y=5000. - require.Len(t, segs, 2) - - // Direction is deterministic and follows the chosen negative-delta representation. - 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]) -} - -// TestLines_NoWrap_TieCaseDoesNotWrap verifies lines No Wrap Tie Case Does Not Wrap. -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) -} - -// TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips verifies draw Points From Plan Duplicates Across Tiles And Clips. -func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { - t.Parallel() - - // World is 10x10 world units => 10000x10000 fixed. - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Place a point near the origin so that expanded canvas (larger than world) - // will require torus repetition and the point will appear in multiple tiles. - id, err := w.AddPoint(1.0, 1.0) // (1000,1000) - require.NoError(t, err) - - // Index only this object. - w.indexObject(w.objects[id]) - - // Choose viewport such that viewport==world in pixels at zoom=1: - // - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px. - // - world width=10 units => 10 px. - // Use margin=2 px on each side => canvas 14x14 px => expanded world 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.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - 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) - // due to expanded rect spanning beyond world on both axes. - wantNames := []string{ - "Save", "ClipRect", "AddPoint", "Fill", "Restore", - "Save", "ClipRect", "AddPoint", "Fill", "Restore", - "Save", "ClipRect", "AddPoint", "Fill", "Restore", - "Save", "ClipRect", "AddPoint", "Fill", "Restore", - } - require.Equal(t, wantNames, d.CommandNames()) - - pointRadiusPx := DefaultRenderStyle().PointRadiusPx - - // Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3). - { - clip := requireDrawerCommandAt(t, d, 1) - require.Equal(t, "ClipRect", clip.Name) - requireCommandArgs(t, clip, 2, 2, 10, 10) - - pt := requireDrawerCommandAt(t, d, 2) - require.Equal(t, "AddPoint", pt.Name) - requireCommandArgs(t, pt, 3, 3, pointRadiusPx) - } - - // Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13). - { - clip := requireDrawerCommandAt(t, d, 6) - require.Equal(t, "ClipRect", clip.Name) - requireCommandArgs(t, clip, 2, 12, 10, 2) - - pt := requireDrawerCommandAt(t, d, 7) - require.Equal(t, "AddPoint", pt.Name) - requireCommandArgs(t, pt, 3, 13, pointRadiusPx) - } - - // Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3). - { - clip := requireDrawerCommandAt(t, d, 11) - require.Equal(t, "ClipRect", clip.Name) - requireCommandArgs(t, clip, 12, 2, 2, 10) - - pt := requireDrawerCommandAt(t, d, 12) - require.Equal(t, "AddPoint", pt.Name) - requireCommandArgs(t, pt, 13, 3, pointRadiusPx) - } - - // Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13). - { - clip := requireDrawerCommandAt(t, d, 16) - require.Equal(t, "ClipRect", clip.Name) - requireCommandArgs(t, clip, 12, 12, 2, 2) - - pt := requireDrawerCommandAt(t, d, 17) - require.Equal(t, "AddPoint", pt.Name) - requireCommandArgs(t, pt, 13, 13, pointRadiusPx) - } -} - -// TestDrawPointsFromPlan_SkipsTilesWithoutPoints verifies draw Points From Plan Skips Tiles Without Points. -func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Add only a line, no points. - id, err := w.AddLine(2, 2, 8, 2) - 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.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawPointsFromPlan(d, plan, true) - - // No points => no drawing commands at all. - require.Empty(t, d.Commands()) -} - -// TestWorldRender_PointsOnlyStageA verifies world Render Points Only Stage A. -func TestWorldRender_PointsOnlyStageA(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - - // Build index. In real UI it happens via IndexOnViewportChange. - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - err = w.Render(d, params) - require.NoError(t, err) - - // At least one point draw should happen. - require.Contains(t, d.CommandNames(), "AddPoint") -} - -// TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies points Wrap Copies Appear Inside Viewport When Viewport Equals World. -func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { - t.Parallel() - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - Style: func() *RenderStyle { - s := DefaultRenderStyle() - s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1 - return &s - }(), - }, - } - - type tc struct { - name string - x, y float64 - wantCenters [][2]float64 - } - - tests := []tc{ - { - name: "bottom boundary wraps to top", - x: 5, - y: 9, - wantCenters: [][2]float64{{5, 9}, {5, -1}}, - }, - { - name: "right boundary wraps to left", - x: 9, - y: 5, - wantCenters: [][2]float64{{9, 5}, {-1, 5}}, - }, - { - name: "corner wraps to three extra copies", - x: 9, - y: 9, - wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}}, - }, - { - name: "no wrap inside", - x: 5, - y: 5, - wantCenters: [][2]float64{{5, 5}}, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(tt.x, tt.y) - require.NoError(t, err) - - for _, obj := range w.objects { - w.indexObject(obj) - } - - plan, err := w.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - style := DefaultRenderStyle() - style.PointRadiusPx = 2.0 - - applyPointStyle(d, style) - drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, true) - - cmds := d.CommandsByName("AddPoint") - require.Len(t, cmds, len(tt.wantCenters)) - - got := make([][2]float64, 0, len(cmds)) - for _, c := range cmds { - require.Len(t, c.Args, 3) - got = append(got, [2]float64{c.Args[0], c.Args[1]}) - } - - require.ElementsMatch(t, tt.wantCenters, got) - }) - } -} - -// TestWorldRender_DrawsAllLayersInDefaultOrder verifies world Render Draws All Layers In Default Order. -func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(1, 1) - require.NoError(t, err) - _, err = w.AddCircle(2, 2, 1) - require.NoError(t, err) - _, err = w.AddLine(9, 5, 1, 5) - require.NoError(t, err) - - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - err = w.Render(d, params) - require.NoError(t, err) - - names := d.CommandNames() - require.Contains(t, names, "AddPoint") - require.Contains(t, names, "AddCircle") - require.Contains(t, names, "AddLine") -} - -// TestSmoke_DrawPointsAndCirclesFromSamePlan verifies smoke Draw Points And Circles From Same Plan. -func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - _, err := w.AddPoint(1, 1) - require.NoError(t, err) - _, err = w.AddCircle(2, 2, 1) - require.NoError(t, err) - - for _, obj := range w.objects { - w.indexObject(obj) - } - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 2, - MarginYPx: 2, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - plan, err := w.buildRenderPlan(params) - require.NoError(t, err) - - d := &fakePrimitiveDrawer{} - drawPointsFromPlan(d, plan, true) - drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) - - names := d.CommandNames() - require.Contains(t, names, "AddPoint") - require.Contains(t, names, "AddCircle") -} - -// TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile verifies render Applies Style Before Add Commands For First Item In Tile. -func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Create a derived circle style so we can observe a style application transition. - red := color.RGBA{R: 255, A: 255} - styleID := w.AddStyleCircle(StyleOverride{FillColor: red}) - - _, err := w.AddCircle(5, 5, 1, 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() - - iSetFill := indexOfFirstName(cmds, "SetFillColor") - iAddCircle := indexOfFirstName(cmds, "AddCircle") - require.NotEqual(t, -1, iSetFill) - require.NotEqual(t, -1, iAddCircle) - - require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle") -} - -// TestRender_DoesNotReapplySameStyleAcrossMultipleObjects verifies render Does Not Reapply Same Style Across Multiple Objects. -func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Two lines with the same default line style and same priority. - _, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100)) - require.NoError(t, err) - _, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority - 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)) - - // We expect style application at least once. - setWidth := d.CommandsByName("SetLineWidth") - require.NotEmpty(t, setWidth) - - // The key batching assertion: style setters should not be called twice *between* two AddLine calls. - cmds := d.Commands() - line1 := indexOfFirstName(cmds, "AddLine") - require.NotEqual(t, -1, line1) - - line2 := indexOfNextName(cmds, "AddLine", line1+1) - require.NotEqual(t, -1, line2) - - // Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset, - // because StyleID is the same and the renderer caches lastStyleID. - for i := line1 + 1; i < line2; i++ { - switch cmds[i].Name { - case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor": - t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i) - } - } -} - -// TestRender_ReappliesStyleWhenStyleIDChanges verifies render Reapplies Style When Style ID Changes. -func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Two circles, different derived fill colors => different StyleIDs. - red := color.RGBA{R: 255, A: 255} - green := color.RGBA{G: 255, A: 255} - - styleRed := w.AddStyleCircle(StyleOverride{FillColor: red}) - styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green}) - - _, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100)) - require.NoError(t, err) - _, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101)) - 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() - firstCircle := indexOfFirstName(cmds, "AddCircle") - secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1) - require.NotEqual(t, -1, firstCircle) - require.NotEqual(t, -1, secondCircle) - - // There must be at least one SetFillColor before each circle. - // And importantly, we expect a SetFillColor BETWEEN the two circles due to style change. - setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle) - require.NotEqual(t, -1, setBeforeFirst) - - setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle) - require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs") -} - -/* ---------- helper functions for fake command slices ---------- */ - -func indexOfFirstName(cmds []fakeDrawerCommand, name string) int { - for i, c := range cmds { - if c.Name == name { - return i - } - } - return -1 -} - -func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int { - for i := start; i < len(cmds); i++ { - if cmds[i].Name == name { - return i - } - } - return -1 -} - -func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int { - if before > len(cmds) { - before = len(cmds) - } - for i := before - 1; i >= 0; i-- { - if cmds[i].Name == name { - return i - } - } - return -1 -} - -func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int { - if start < 0 { - start = 0 - } - if end > len(cmds) { - end = len(cmds) - } - for i := start; i < end; i++ { - if cmds[i].Name == name { - return i - } - } - return -1 -} - -// rendererTestEnv groups the common mutable inputs used by renderer tests. -// The environment stores independent horizontal and vertical margins because -// the expanded canvas geometry is derived separately on each axis. -type rendererTestEnv struct { - world *World - drawer *fakePrimitiveDrawer - - // Viewport origin and size in canvas pixel coordinates. - viewportX int - viewportY int - viewportW int - viewportH int - - // Independent margins around the viewport in canvas pixels. - marginXPx int - marginYPx int - - // Final expanded canvas size in pixels. - // In the default setup: - // canvasW = viewportW + 2*marginXPx - // canvasH = viewportH + 2*marginYPx - canvasW int - canvasH int - - // Camera center in fixed-point world coordinates. - cameraX int - cameraY int - - // Camera zoom in fixed-point representation, if needed by renderer internals. - zoomFp int -} - -// newRendererTestEnv returns a baseline renderer test environment. -// The default margins are derived independently from viewport width and height. -func newRendererTestEnv() *rendererTestEnv { - viewportW := 100 - viewportH := 80 - - marginXPx := viewportW / 4 - marginYPx := viewportH / 4 - - return &rendererTestEnv{ - world: NewWorld(10, 10), - drawer: &fakePrimitiveDrawer{}, - viewportX: marginXPx, - viewportY: marginYPx, - viewportW: viewportW, - viewportH: viewportH, - marginXPx: marginXPx, - marginYPx: marginYPx, - canvasW: viewportW + 2*marginXPx, - canvasH: viewportH + 2*marginYPx, - cameraX: 5 * SCALE, - cameraY: 5 * SCALE, - zoomFp: SCALE, - } -} - -// setViewport resets viewport-dependent fields and recomputes margins -// using the default test formula: -// -// marginXPx = viewportW / 4 -// marginYPx = viewportH / 4 -func (env *rendererTestEnv) setViewport(viewportW, viewportH int) { - env.viewportW = viewportW - env.viewportH = viewportH - - env.marginXPx = viewportW / 4 - env.marginYPx = viewportH / 4 - - env.viewportX = env.marginXPx - env.viewportY = env.marginYPx - - env.canvasW = env.viewportW + 2*env.marginXPx - env.canvasH = env.viewportH + 2*env.marginYPx -} - -// setViewportAndMargins overrides viewport and margins explicitly. -// This is useful for edge cases where the expanded canvas geometry -// must be controlled exactly. -func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) { - env.viewportW = viewportW - env.viewportH = viewportH - - env.marginXPx = marginXPx - env.marginYPx = marginYPx - - env.viewportX = env.marginXPx - env.viewportY = env.marginYPx - - env.canvasW = env.viewportW + 2*env.marginXPx - env.canvasH = env.viewportH + 2*env.marginYPx -} - -// viewportRect returns the viewport rectangle in canvas pixel coordinates. -func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) { - return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH) -} - -// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates. -func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) { - return 0, 0, float64(env.canvasW), float64(env.canvasH) -} - -// worldMustAddPoint adds a point to the test world and fails the test on error. -func worldMustAddPoint(t *testing.T, w *World, x, y float64) { - t.Helper() - - _, err := w.AddPoint(x, y) - require.NoError(t, err) -} - -// worldMustAddCircle adds a circle to the test world and fails the test on error. -func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) { - t.Helper() - - _, err := w.AddCircle(x, y, r) - require.NoError(t, err) -} - -// worldMustAddLine adds a line to the test world and fails the test on error. -func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) { - t.Helper() - - _, err := w.AddLine(x1, y1, x2, y2) - require.NoError(t, err) -} - -// requireNoDrawerCommands asserts that the renderer produced no drawing commands. -func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) { - t.Helper() - - require.Empty(t, d.Commands()) -} - -// requireStrokeCommandAt returns a command and asserts that it is Stroke. -func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmd := requireDrawerCommandAt(t, d, index) - requireCommandName(t, cmd, "Stroke") - return cmd -} - -// requireFillCommandAt returns a command and asserts that it is Fill. -func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmd := requireDrawerCommandAt(t, d, index) - requireCommandName(t, cmd, "Fill") - return cmd -} - -// requireAddPointCommandAt returns a command and asserts that it is AddPoint. -func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmd := requireDrawerCommandAt(t, d, index) - requireCommandName(t, cmd, "AddPoint") - return cmd -} - -// requireAddLineCommandAt returns a command and asserts that it is AddLine. -func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmd := requireDrawerCommandAt(t, d, index) - requireCommandName(t, cmd, "AddLine") - return cmd -} - -// requireAddCircleCommandAt returns a command and asserts that it is AddCircle. -func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { - t.Helper() - - cmd := requireDrawerCommandAt(t, d, index) - requireCommandName(t, cmd, "AddCircle") - return cmd -} - -// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect. -func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) { - t.Helper() - - requireCommandClipRects(t, cmd, fakeClipRect{ - X: x, - Y: y, - W: w, - H: h, - }) -} - -// rendererTestCase is a generic table-driven renderer test scaffold. -// Replace invoke with the real renderer call once the renderer exists. -type rendererTestCase struct { - name string - - // setup prepares the world and optional environment overrides. - setup func(t *testing.T, env *rendererTestEnv) - - // invoke calls the renderer under test. - invoke func(t *testing.T, env *rendererTestEnv) - - // verify checks the produced fake drawer log. - verify func(t *testing.T, env *rendererTestEnv) -} - -func runRendererTestCases(t *testing.T, cases []rendererTestCase) { - t.Helper() - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - env := newRendererTestEnv() - - if tc.setup != nil { - tc.setup(t, env) - } - - require.NotNil(t, tc.invoke, "renderer test case must define invoke") - require.NotNil(t, tc.verify, "renderer test case must define verify") - - tc.invoke(t, env) - tc.verify(t, env) - }) - } -} - -// TestRenderer_Template_PointCases is a scaffold for future point renderer tests. -func TestRenderer_Template_PointCases(t *testing.T) { - t.Parallel() - - runRendererTestCases(t, []rendererTestCase{ - { - name: "point fully inside viewport", - setup: func(t *testing.T, env *rendererTestEnv) { - worldMustAddPoint(t, env.world, 5, 5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "point visible only in horizontal margin copy", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(160, 40) - worldMustAddPoint(t, env.world, 0.1, 5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "point visible only in vertical margin copy", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(40, 160) - worldMustAddPoint(t, env.world, 5, 0.1) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "point duplicated across torus corner with independent margins", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewportAndMargins(120, 60, 30, 10) - worldMustAddPoint(t, env.world, 0.1, 0.1) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - }) -} - -// TestRenderer_Template_LineCases is a scaffold for future line renderer tests. -func TestRenderer_Template_LineCases(t *testing.T) { - t.Parallel() - - runRendererTestCases(t, []rendererTestCase{ - { - name: "line fully inside viewport", - setup: func(t *testing.T, env *rendererTestEnv) { - worldMustAddLine(t, env.world, 2, 2, 8, 2) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "line wrap copy across x edge", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(160, 40) - worldMustAddLine(t, env.world, 9, 5, 1, 5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "line wrap copy across y edge", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(40, 160) - worldMustAddLine(t, env.world, 5, 9, 5, 1) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "line tie case uses deterministic wrapped representation", - setup: func(t *testing.T, env *rendererTestEnv) { - worldMustAddLine(t, env.world, 1, 5, 6, 5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - }) -} - -// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests. -func TestRenderer_Template_CircleCases(t *testing.T) { - t.Parallel() - - runRendererTestCases(t, []rendererTestCase{ - { - name: "circle fully inside viewport", - setup: func(t *testing.T, env *rendererTestEnv) { - worldMustAddCircle(t, env.world, 5, 5, 1) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "circle duplicated across horizontal edge", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(160, 40) - worldMustAddCircle(t, env.world, 0.2, 5, 0.5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "circle duplicated across vertical edge", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewport(40, 160) - worldMustAddCircle(t, env.world, 5, 0.2, 0.5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - { - name: "circle duplicated across corner with asymmetric margins", - setup: func(t *testing.T, env *rendererTestEnv) { - env.setViewportAndMargins(120, 60, 30, 10) - worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5) - }, - invoke: func(t *testing.T, env *rendererTestEnv) { - t.Skip("replace with actual renderer call") - }, - verify: func(t *testing.T, env *rendererTestEnv) { - requireNoDrawerCommands(t, env.drawer) - }, - }, - }) -} - -type pointRadiusTheme struct { - id string - radius float64 -} - -func (t pointRadiusTheme) ID() string { return t.id } -func (t pointRadiusTheme) Name() string { return t.id } - -func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (t pointRadiusTheme) BackgroundImage() image.Image { return nil } - -func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } - -func (t pointRadiusTheme) PointStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius} -} -func (t pointRadiusTheme) LineStyle() Style { - return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (t pointRadiusTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles verifies render Theme Change Applies Without Reindex Uses Latest Object Styles. -func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - // Build index once. - w.IndexOnViewportChange(100, 100, 1.0) - - // Theme A: point radius 2 - w.SetTheme(pointRadiusTheme{id: "A", radius: 2}) - - _, err := w.AddPoint(5, 5) - require.NoError(t, err) - - // Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state). - // Render once. - params := RenderParams{ - ViewportWidthPx: 100, - ViewportHeightPx: 100, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d1 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d1, params)) - - p1 := d1.CommandsByName("AddPoint") - require.NotEmpty(t, p1) - r1 := p1[0].Args[2] - require.Equal(t, 2.0, r1) - - // Theme B: point radius 7. Change theme, but DO NOT reindex. - w.SetTheme(pointRadiusTheme{id: "B", radius: 7}) - - d2 := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d2, params)) - - p2 := d2.CommandsByName("AddPoint") - require.NotEmpty(t, p2) - r2 := p2[0].Args[2] - require.Equal(t, 7.0, r2) -} diff --git a/client/world/style.go b/client/world/style.go deleted file mode 100644 index f09c169..0000000 --- a/client/world/style.go +++ /dev/null @@ -1,695 +0,0 @@ -package world - -import ( - "image" - "image/color" - "sync" -) - -// StyleID references a fully-materialized style stored in StyleTable. -type StyleID int - -const ( - // StyleIDInvalid means "no style". It should not be used for rendering. - StyleIDInvalid StyleID = 0 - - // Built-in default styles (stable IDs). - StyleIDDefaultLine StyleID = 1 - StyleIDDefaultCircle StyleID = 2 - StyleIDDefaultPoint StyleID = 3 -) - -// Default priorities (smaller draws earlier), step=100. -const ( - DefaultPriorityLine = 100 - DefaultPriorityCircle = 200 - DefaultPriorityPoint = 300 -) - -var ( - transparentColor color.Color = &color.RGBA{A: 0} -) - -// TransparentFill returns a reusable fully transparent color value. -// -// It is intended for callers that want to explicitly disable fill while still -// setting a non-nil FillColor override. -func TransparentFill() color.Color { return transparentColor } - -// Style is a fully resolved style used by the renderer. -// All fields are concrete values; no "optional" markers here. -// Optionality is handled by StyleOverride during style creation. -type Style struct { - // FillColor is used for Fill() operations (points/circles typically). - // If nil, the renderer may treat it as "do not fill" depending on primitive. - FillColor color.Color - - // StrokeColor is used for Stroke() operations (lines typically). - // If nil, the renderer may treat it as "do not stroke" depending on primitive. - StrokeColor color.Color - - // StrokeWidthPx is a screen-space stroke width in pixels. - StrokeWidthPx float64 - - // StrokeDashes is the dash pattern in pixels. nil/empty means "solid". - StrokeDashes []float64 - - // StrokeDashOffset is the dash phase in pixels. - StrokeDashOffset float64 - - // PointRadiusPx is a screen-space radius for Point markers. - PointRadiusPx float64 -} - -// StyleOverride describes partial modifications applied to a base Style. -// Fields set to nil mean "do not override". -type StyleOverride struct { - FillColor color.Color - StrokeColor color.Color - StrokeWidthPx *float64 - StrokeDashes *[]float64 - StrokeDashOffset *float64 - PointRadiusPx *float64 -} - -// IsZero reports whether override does not specify any fields. -func (o StyleOverride) IsZero() bool { - return o.FillColor == nil && - o.StrokeColor == nil && - o.StrokeWidthPx == nil && - o.StrokeDashes == nil && - o.StrokeDashOffset == nil && - o.PointRadiusPx == nil -} - -// Apply applies override to base style and returns a new fully resolved style. -// It copies slices defensively to avoid aliasing. -func (o StyleOverride) Apply(base Style) Style { - out := base - - if o.FillColor != nil { - out.FillColor = o.FillColor - } - if o.StrokeColor != nil { - out.StrokeColor = o.StrokeColor - } - if o.StrokeWidthPx != nil { - out.StrokeWidthPx = *o.StrokeWidthPx - } - if o.StrokeDashes != nil { - // Copy to avoid future mutation by caller. - src := *o.StrokeDashes - if src == nil { - out.StrokeDashes = nil - } else { - dst := make([]float64, len(src)) - copy(dst, src) - out.StrokeDashes = dst - } - } - if o.StrokeDashOffset != nil { - out.StrokeDashOffset = *o.StrokeDashOffset - } - if o.PointRadiusPx != nil { - out.PointRadiusPx = *o.PointRadiusPx - } - - return out -} - -// StyleTable stores fully resolved styles and provides stable lookups by StyleID. -// It also holds three built-in defaults for Line/Circle/Point. -type StyleTable struct { - mu sync.RWMutex - nextID StyleID - styles map[StyleID]Style -} - -// NewStyleTable creates a new style table with built-in default styles. -// The default values are intentionally simple and stable. -func NewStyleTable() *StyleTable { - t := &StyleTable{ - nextID: StyleIDDefaultPoint + 1, - styles: make(map[StyleID]Style, 16), - } - - // Defaults: conservative, deterministic. - // Colors: opaque black. (Callers can override.) - white := color.RGBA{R: 255, G: 255, B: 255, A: 255} - - t.styles[StyleIDDefaultLine] = Style{ - FillColor: nil, - StrokeColor: white, - StrokeWidthPx: 2.0, - StrokeDashes: nil, - StrokeDashOffset: 0, - PointRadiusPx: 0, - } - - t.styles[StyleIDDefaultCircle] = Style{ - FillColor: white, - StrokeColor: nil, - StrokeWidthPx: 0, - StrokeDashes: nil, - StrokeDashOffset: 0, - PointRadiusPx: 0, - } - - t.styles[StyleIDDefaultPoint] = Style{ - FillColor: white, - StrokeColor: nil, - StrokeWidthPx: 0, - StrokeDashes: nil, - StrokeDashOffset: 0, - PointRadiusPx: 2.0, - } - - return t -} - -// Get returns a style by id. -func (t *StyleTable) Get(id StyleID) (Style, bool) { - t.mu.RLock() - defer t.mu.RUnlock() - s, ok := t.styles[id] - if !ok { - return Style{}, false - } - // Defensive copy of slices. - if s.StrokeDashes != nil { - cp := make([]float64, len(s.StrokeDashes)) - copy(cp, s.StrokeDashes) - s.StrokeDashes = cp - } - return s, true -} - -// AddDerived creates a new style based on baseID with an override applied. -// It returns the new style ID. -func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID { - t.mu.Lock() - defer t.mu.Unlock() - - base, ok := t.styles[baseID] - if !ok { - panic("StyleTable.AddDerived: unknown base style ID") - } - - derived := override.Apply(base) - - id := t.nextID - t.nextID++ - - // Defensive copy of slices on store. - if derived.StrokeDashes != nil { - cp := make([]float64, len(derived.StrokeDashes)) - copy(cp, derived.StrokeDashes) - derived.StrokeDashes = cp - } - - t.styles[id] = derived - return id -} - -// AddStyle stores a fully resolved style as a new StyleID. -// It defensively copies slice fields. -func (t *StyleTable) AddStyle(s Style) StyleID { - t.mu.Lock() - defer t.mu.Unlock() - - id := t.nextID - t.nextID++ - - if s.StrokeDashes != nil { - cp := make([]float64, len(s.StrokeDashes)) - copy(cp, s.StrokeDashes) - s.StrokeDashes = cp - } - - t.styles[id] = s - return id -} - -// Count returns the number of styles stored in the table. -// Intended for tests/diagnostics. -func (t *StyleTable) Count() int { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.styles) -} - -// BackgroundTileMode defines how the background image is tiled. -type BackgroundTileMode uint8 - -const ( - BackgroundTileNone BackgroundTileMode = iota - BackgroundTileRepeat -) - -// BackgroundAnchorMode defines whether the background image scrolls with the world or stays fixed to viewport. -type BackgroundAnchorMode uint8 - -const ( - BackgroundAnchorWorld BackgroundAnchorMode = iota - BackgroundAnchorViewport -) - -// BackgroundScaleMode defines how the background image is scaled. -// (Step 1: defined for API completeness; used later when rendering background image.) -type BackgroundScaleMode uint8 - -const ( - BackgroundScaleNone BackgroundScaleMode = iota - BackgroundScaleFit - BackgroundScaleFill -) - -// StyleTheme describes a cohesive style set (theme) for rendering. -// Step 1: we store it in World and use it for background and default base styles. -// Step 2+: theme-relative overrides and background image drawing. -type StyleTheme interface { - ID() string - Name() string - - BackgroundColor() color.Color - BackgroundImage() image.Image - - BackgroundTileMode() BackgroundTileMode - BackgroundScaleMode() BackgroundScaleMode - BackgroundAnchorMode() BackgroundAnchorMode - - PointStyle() Style - LineStyle() Style - CircleStyle() Style - - // Class overrides (relative to base kind style). - // Return (override, true) when class is supported; (zero, false) means "no override". - PointClassOverride(class PointClassID) (StyleOverride, bool) - LineClassOverride(class LineClassID) (StyleOverride, bool) - CircleClassOverride(class CircleClassID) (StyleOverride, bool) -} - -// DefaultTheme is a conservative theme matching built-in default styles. -type DefaultTheme struct{} - -func (DefaultTheme) ID() string { return "default" } -func (DefaultTheme) Name() string { return "Default" } - -func (DefaultTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (DefaultTheme) BackgroundImage() image.Image { return nil } - -func (DefaultTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (DefaultTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (DefaultTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } - -func (DefaultTheme) PointStyle() Style { - s, _ := NewStyleTable().Get(StyleIDDefaultPoint) - return s -} -func (DefaultTheme) LineStyle() Style { - s, _ := NewStyleTable().Get(StyleIDDefaultLine) - return s -} -func (DefaultTheme) CircleStyle() Style { - s, _ := NewStyleTable().Get(StyleIDDefaultCircle) - return s -} - -func (DefaultTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (DefaultTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (DefaultTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// This file provides two sample themes for demos and UI integration: -// LightTheme uses only background color, while DarkTheme also carries a -// prebuilt tiled texture image. - -var ( - // ThemeLight is the shared light theme instance used by the client package. - ThemeLight = &LightTheme{} - // ThemeDark is the shared dark theme instance used by the client package. - ThemeDark = NewDarkTheme() -) - -// ----------------------------- -// Helpers -// ----------------------------- - -// cRGBA constructs an sRGB color from 8-bit RGBA channels. -func cRGBA(r, g, b, a uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: a} } - -// ----------------------------- -// Light Theme (color only) -// ----------------------------- - -// LightTheme is a soft high-contrast theme intended for bright backgrounds. -type LightTheme struct{} - -func (LightTheme) ID() string { return "theme.light.v1" } -func (LightTheme) Name() string { return "Light (Soft)" } - -func (LightTheme) BackgroundColor() color.Color { return cRGBA(244, 246, 248, 255) } // #F4F6F8 -func (LightTheme) BackgroundImage() image.Image { return nil } - -func (LightTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (LightTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (LightTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } - -// Base styles per primitive kind (full Style, not override). -func (LightTheme) PointStyle() Style { - return Style{ - FillColor: cRGBA(32, 161, 145, 255), // soft teal - StrokeColor: nil, - StrokeWidthPx: 0, - PointRadiusPx: 3.0, - } -} - -func (LightTheme) LineStyle() Style { - return Style{ - FillColor: nil, - StrokeColor: cRGBA(70, 108, 196, 220), // soft blue - StrokeWidthPx: 2.0, - StrokeDashes: nil, - StrokeDashOffset: 0, - } -} - -func (LightTheme) CircleStyle() Style { - return Style{ - FillColor: cRGBA(133, 110, 201, 60), // soft purple with low alpha - StrokeColor: cRGBA(133, 110, 201, 200), // soft purple - StrokeWidthPx: 2.0, - } -} - -// Point class overrides. -func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { - switch class { - case PointClassDefault: - return StyleOverride{}, false - - case PointClassTrackUnknown: - // muted gray-blue - return StyleOverride{ - FillColor: cRGBA(120, 135, 160, 230), - PointRadiusPx: new(3.0), - }, true - - case PointClassTrackIncoming: - // soft green - return StyleOverride{ - FillColor: cRGBA(76, 171, 107, 240), - PointRadiusPx: new(3.5), - }, true - - case PointClassTrackOutgoing: - // soft orange - return StyleOverride{ - FillColor: cRGBA(222, 142, 70, 240), - PointRadiusPx: new(3.5), - }, true - - case PointClassUnidentifiedPlanet: - // soft orange - return StyleOverride{ - FillColor: cRGBA(192, 192, 192, 255), - PointRadiusPx: new(2.5), - }, true - - default: - return StyleOverride{}, false - } -} - -func (LightTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) { - switch class { - case LineClassDefault: - return StyleOverride{}, false - - case LineClassTrackIncoming: - return StyleOverride{ - StrokeColor: cRGBA(76, 171, 107, 220), - StrokeWidthPx: new(2.5), - }, true - - case LineCLassTrackOutgoing: - return StyleOverride{ - StrokeColor: cRGBA(222, 142, 70, 220), - StrokeWidthPx: new(2.5), - }, true - - case LineClassMeasurement: - // dashed neutral line - d := []float64{6, 4} - return StyleOverride{ - StrokeColor: cRGBA(100, 110, 125, 200), - StrokeWidthPx: new(1.8), - StrokeDashes: &d, - StrokeDashOffset: new(0.), - }, true - - default: - return StyleOverride{}, false - } -} - -func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) { - switch class { - case CircleClassDefault: - return StyleOverride{}, false - - case CircleClassLocalPlanet: - // blue - return StyleOverride{ - FillColor: cRGBA(70, 108, 196, 45), - StrokeColor: cRGBA(70, 108, 196, 220), - StrokeWidthPx: new(2.2), - }, true - - case CircleClassOthersPlanet: - // orange - return StyleOverride{ - FillColor: cRGBA(222, 142, 70, 50), - StrokeColor: cRGBA(222, 142, 70, 220), - StrokeWidthPx: new(2.2), - }, true - - case CircleClassFreePlanet: - // green - return StyleOverride{ - FillColor: cRGBA(76, 171, 107, 45), - StrokeColor: cRGBA(76, 171, 107, 220), - StrokeWidthPx: new(2.2), - }, true - - default: - return StyleOverride{}, false - } -} - -// ----------------------------- -// Dark Theme (color + tiled image) -// ----------------------------- - -// DarkTheme is a dark theme with an optional reusable background tile. -type DarkTheme struct { - bg image.Image -} - -// NewDarkTheme constructs a DarkTheme with its immutable texture tile prepared. -func NewDarkTheme() *DarkTheme { - return &DarkTheme{bg: makeDarkBackgroundTile(96, 96)} -} - -func (*DarkTheme) ID() string { return "theme.dark.v1" } -func (*DarkTheme) Name() string { return "Dark (Soft + Texture)" } - -func (*DarkTheme) BackgroundColor() color.Color { return cRGBA(30, 32, 38, 255) } // #1E2026 -func (t *DarkTheme) BackgroundImage() image.Image { - return nil - // This image is immutable after creation. - // return t.bg -} - -func (*DarkTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } -func (*DarkTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (*DarkTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } - -// Base styles for dark theme. -func (*DarkTheme) PointStyle() Style { - return Style{ - FillColor: cRGBA(120, 214, 198, 255), - StrokeColor: nil, - StrokeWidthPx: 0, - PointRadiusPx: 3.0, - } -} - -func (*DarkTheme) LineStyle() Style { - return Style{ - FillColor: nil, - StrokeColor: cRGBA(155, 175, 235, 255), - StrokeWidthPx: 2.0, - StrokeDashes: nil, - StrokeDashOffset: 0, - } -} - -func (*DarkTheme) CircleStyle() Style { - return Style{ - FillColor: nil, // cRGBA(186, 160, 255, 255), - StrokeColor: cRGBA(186, 160, 255, 255), - StrokeWidthPx: 2.0, - } -} - -// Point class overrides. -func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { - switch class { - case PointClassDefault: - return StyleOverride{}, false - - case PointClassTrackUnknown: - return StyleOverride{ - FillColor: cRGBA(150, 160, 175, 255), - PointRadiusPx: new(3.0), - }, true - - case PointClassTrackIncoming: - return StyleOverride{ - FillColor: cRGBA(132, 219, 162, 255), - PointRadiusPx: new(3.5), - }, true - - case PointClassTrackOutgoing: - return StyleOverride{ - FillColor: cRGBA(245, 178, 120, 255), - PointRadiusPx: new(3.5), - }, true - - case PointClassUnidentifiedPlanet: - return StyleOverride{ - FillColor: cRGBA(192, 192, 192, 255), - PointRadiusPx: new(2.5), - }, true - - default: - return StyleOverride{}, false - } -} - -func (*DarkTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) { - switch class { - case LineClassDefault: - return StyleOverride{}, false - - case LineClassTrackIncoming: - return StyleOverride{ - StrokeColor: cRGBA(132, 219, 162, 255), - StrokeWidthPx: new(2.5), - }, true - - case LineCLassTrackOutgoing: - return StyleOverride{ - StrokeColor: cRGBA(245, 178, 120, 255), - StrokeWidthPx: new(2.5), - }, true - - case LineClassMeasurement: - d := []float64{6, 4} - return StyleOverride{ - StrokeColor: cRGBA(170, 175, 190, 255), - StrokeWidthPx: new(1.8), - StrokeDashes: &d, - StrokeDashOffset: new(0.), - }, true - - default: - return StyleOverride{}, false - } -} - -func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) { - switch class { - case CircleClassDefault: - return StyleOverride{}, false - - case CircleClassLocalPlanet: - return StyleOverride{ - FillColor: cRGBA(155, 175, 235, 255), - StrokeColor: cRGBA(155, 175, 235, 255), - StrokeWidthPx: new(2.2), - }, true - - case CircleClassOthersPlanet: - return StyleOverride{ - FillColor: cRGBA(245, 178, 120, 255), - StrokeColor: cRGBA(245, 178, 120, 255), - StrokeWidthPx: new(2.2), - }, true - - case CircleClassFreePlanet: - return StyleOverride{ - FillColor: cRGBA(132, 219, 162, 255), - StrokeColor: cRGBA(132, 219, 162, 255), - StrokeWidthPx: new(2.2), - }, true - - default: - return StyleOverride{}, false - } -} - -// makeDarkBackgroundTile creates a subtle, low-contrast texture tile. -// It is intentionally simple: a faint grid + a few diagonal accents. -// The tile is meant to be repeated. -func makeDarkBackgroundTile(w, h int) image.Image { - if w <= 0 || h <= 0 { - return nil - } - img := image.NewRGBA(image.Rect(0, 0, w, h)) - - // Base is transparent; background color is drawn separately. - // We draw subtle strokes with low alpha. - grid := color.RGBA{R: 255, G: 255, B: 255, A: 12} // very faint - diag := color.RGBA{R: 255, G: 255, B: 255, A: 18} // slightly stronger - dots := color.RGBA{R: 255, G: 255, B: 255, A: 10} // faint dots - - // Grid spacing (pixels). - const step = 12 - - // Vertical grid lines. - for x := 0; x < w; x += step { - for y := 0; y < h; y++ { - img.SetRGBA(x, y, grid) - } - } - // Horizontal grid lines. - for y := 0; y < h; y += step { - for x := 0; x < w; x++ { - img.SetRGBA(x, y, grid) - } - } - - // Diagonal accents (sparse). - for x := 0; x < w; x += step * 2 { - for i := 0; i < step && x+i < w && i < h; i++ { - img.SetRGBA(x+i, i, diag) - } - } - - // Small dot pattern. - for y := step / 2; y < h; y += step { - for x := step / 2; x < w; x += step { - img.SetRGBA(x, y, dots) - } - } - - return img -} diff --git a/client/world/style_test.go b/client/world/style_test.go deleted file mode 100644 index a8bd7a6..0000000 --- a/client/world/style_test.go +++ /dev/null @@ -1,569 +0,0 @@ -package world - -import ( - "github.com/stretchr/testify/require" - "image" - "image/color" - "testing" -) - -// TestStyleOverrideApply_OverridesOnlyProvidedFields verifies style Override Apply Overrides Only Provided Fields. -func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) { - t.Parallel() - - base := Style{ - FillColor: color.RGBA{R: 1, A: 255}, - StrokeColor: color.RGBA{G: 2, A: 255}, - StrokeWidthPx: 1.0, - StrokeDashes: []float64{3, 1}, - StrokeDashOffset: 0.5, - PointRadiusPx: 2.0, - } - - newWidth := 5.0 - newRadius := 7.0 - - override := StyleOverride{ - StrokeWidthPx: &newWidth, - PointRadiusPx: &newRadius, - // Everything else is unset (nil) => must remain from base. - } - - out := override.Apply(base) - - require.Equal(t, base.FillColor, out.FillColor) - require.Equal(t, base.StrokeColor, out.StrokeColor) - require.Equal(t, 5.0, out.StrokeWidthPx) - require.Equal(t, base.StrokeDashes, out.StrokeDashes) - require.Equal(t, base.StrokeDashOffset, out.StrokeDashOffset) - require.Equal(t, 7.0, out.PointRadiusPx) -} - -// TestStyleTable_DefaultsExistAndAreStable verifies style Table Defaults Exist And Are Stable. -func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) { - t.Parallel() - - tbl := NewStyleTable() - - _, ok := tbl.Get(StyleIDDefaultLine) - require.True(t, ok) - - _, ok = tbl.Get(StyleIDDefaultCircle) - require.True(t, ok) - - _, ok = tbl.Get(StyleIDDefaultPoint) - require.True(t, ok) -} - -// TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices verifies style Table Add Derived Stores Resolved Style And Copies Slices. -func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T) { - t.Parallel() - - tbl := NewStyleTable() - - dashes := []float64{10, 5} - override := StyleOverride{ - StrokeDashes: &dashes, - } - id := tbl.AddDerived(StyleIDDefaultLine, override) - - got, ok := tbl.Get(id) - require.True(t, ok) - require.Equal(t, []float64{10, 5}, got.StrokeDashes) - - // Mutate caller slice; table must not change. - dashes[0] = 999 - - got2, ok := tbl.Get(id) - require.True(t, ok) - require.Equal(t, []float64{10, 5}, got2.StrokeDashes) - - // Mutate returned slice; table must not change. - got2.StrokeDashes[0] = 123 - - got3, ok := tbl.Get(id) - require.True(t, ok) - require.Equal(t, []float64{10, 5}, got3.StrokeDashes) -} - -// TestDefaultPriorities_AreOrderedAndStepped verifies default Priorities Are Ordered And Stepped. -func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) { - t.Parallel() - - require.Equal(t, 100, DefaultPriorityLine) - require.Equal(t, 200, DefaultPriorityCircle) - require.Equal(t, 300, DefaultPriorityPoint) - - require.Less(t, DefaultPriorityLine, DefaultPriorityCircle) - require.Less(t, DefaultPriorityCircle, DefaultPriorityPoint) -} - -type cacheTheme struct{} - -func (cacheTheme) ID() string { return "cache" } -func (cacheTheme) Name() string { return "cache" } -func (cacheTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (cacheTheme) BackgroundImage() image.Image { return nil } -func (cacheTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (cacheTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (cacheTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } -func (cacheTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } -func (cacheTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } -func (cacheTheme) CircleStyle() Style { - return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (cacheTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (cacheTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (cacheTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -type cacheTheme2 struct{ cacheTheme } - -func (cacheTheme2) ID() string { return "cache2" } -func (cacheTheme2) Name() string { return "cache2" } -func (cacheTheme2) CircleStyle() Style { - return Style{FillColor: color.RGBA{B: 200, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 3} -} -func (cacheTheme2) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (cacheTheme2) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (cacheTheme2) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -// TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes verifies derived Style Cache Reuses Derived Styles Across Objects And Themes. -func TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(cacheTheme{}) - - before := w.styles.Count() - - white := color.RGBA{R: 255, G: 255, B: 255, A: 255} - ov := StyleOverride{StrokeColor: white} - - id1, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov)) - require.NoError(t, err) - id2, err := w.AddCircle(6, 5, 2, CircleWithStyleOverride(ov)) - require.NoError(t, err) - - c1 := w.objects[id1].(Circle) - c2 := w.objects[id2].(Circle) - require.Equal(t, c1.StyleID, c2.StyleID, "same override must reuse derived style ID") - - afterAdd := w.styles.Count() - require.Equal(t, before+1, afterAdd, "only one derived style should be added for identical overrides") - - // Change theme: derived cache is cleared and new base IDs are created; override must still be applied, - // and both objects should again share one derived style for the new base. - w.SetTheme(cacheTheme2{}) - - c1b := w.objects[id1].(Circle) - c2b := w.objects[id2].(Circle) - require.Equal(t, c1b.StyleID, c2b.StyleID) - - afterTheme := w.styles.Count() - // Theme change creates 3 new theme default styles + 1 new derived for the override. - require.GreaterOrEqual(t, afterTheme, afterAdd+4) -} - -type testTheme struct{} - -func (testTheme) ID() string { return "t1" } -func (testTheme) Name() string { return "Theme1" } - -func (testTheme) BackgroundColor() color.Color { return color.RGBA{R: 1, G: 2, B: 3, A: 255} } -func (testTheme) BackgroundImage() image.Image { return nil } - -func (testTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } -func (testTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (testTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } -func (testTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (testTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (testTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} - -func (testTheme) PointStyle() Style { - return Style{ - FillColor: color.RGBA{R: 9, A: 255}, - StrokeColor: nil, - StrokeWidthPx: 0, - StrokeDashes: nil, - StrokeDashOffset: 0, - PointRadiusPx: 4, - } -} -func (testTheme) LineStyle() Style { - return Style{ - FillColor: nil, - StrokeColor: color.RGBA{G: 9, A: 255}, - StrokeWidthPx: 3, - StrokeDashes: []float64{2, 2}, - StrokeDashOffset: 1, - PointRadiusPx: 0, - } -} -func (testTheme) CircleStyle() Style { - return Style{ - FillColor: color.RGBA{B: 9, A: 255}, - StrokeColor: color.RGBA{A: 255}, - StrokeWidthPx: 2, - StrokeDashes: nil, - StrokeDashOffset: 0, - PointRadiusPx: 0, - } -} - -// TestWorldSetTheme_MaterializesThemeDefaultStyles verifies world Set Theme Materializes Theme Default Styles. -func TestWorldSetTheme_MaterializesThemeDefaultStyles(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - // Built-ins should remain stable (1/2/3). - require.Equal(t, StyleIDDefaultLine, StyleID(1)) - require.Equal(t, StyleIDDefaultCircle, StyleID(2)) - require.Equal(t, StyleIDDefaultPoint, StyleID(3)) - - // Set a custom theme. - w.SetTheme(testTheme{}) - - // Theme defaults should NOT be built-in IDs anymore. - require.NotEqual(t, StyleIDDefaultLine, w.themeDefaultLineStyleID) - require.NotEqual(t, StyleIDDefaultCircle, w.themeDefaultCircleStyleID) - require.NotEqual(t, StyleIDDefaultPoint, w.themeDefaultPointStyleID) - - ls, ok := w.styles.Get(w.themeDefaultLineStyleID) - require.True(t, ok) - require.Equal(t, 3.0, ls.StrokeWidthPx) - require.Equal(t, []float64{2, 2}, ls.StrokeDashes) - require.Equal(t, 1.0, ls.StrokeDashOffset) - - cs, ok := w.styles.Get(w.themeDefaultCircleStyleID) - require.True(t, ok) - require.Equal(t, 2.0, cs.StrokeWidthPx) - - ps, ok := w.styles.Get(w.themeDefaultPointStyleID) - require.True(t, ok) - require.Equal(t, 4.0, ps.PointRadiusPx) -} - -// TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride verifies render Uses Theme Background Color When No Option Override. -func TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(testTheme{}) - - // Minimal index. - w.resetGrid(2 * SCALE) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - - // Should clear with theme background color via ClearAllTo(bg). - require.NotEmpty(t, d.CommandsByName("ClearAllTo")) -} - -// TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor verifies render Options Background Color Overrides Theme Background Color. -func TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(testTheme{}) - - w.resetGrid(2 * SCALE) - - params := RenderParams{ - ViewportWidthPx: 10, - ViewportHeightPx: 10, - MarginXPx: 0, - MarginYPx: 0, - CameraXWorldFp: 5 * SCALE, - CameraYWorldFp: 5 * SCALE, - CameraZoom: 1.0, - Options: &RenderOptions{ - BackgroundColor: color.RGBA{R: 200, A: 255}, - }, - } - - d := &fakePrimitiveDrawer{} - require.NoError(t, w.Render(d, params)) - require.NotEmpty(t, d.CommandsByName("ClearAllTo")) -} - -const ( - testPointClassExtended PointClassID = 1 - testCircleClassExtended CircleClassID = 1 -) - -type classThemeA struct{} - -func (classThemeA) ID() string { return "classA" } -func (classThemeA) Name() string { return "classA" } -func (classThemeA) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (classThemeA) BackgroundImage() image.Image { return nil } -func (classThemeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (classThemeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (classThemeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } - -func (classThemeA) PointStyle() Style { - return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2} -} -func (classThemeA) LineStyle() Style { - return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1} -} -func (classThemeA) CircleStyle() Style { - return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} - -func (classThemeA) PointClassOverride(c PointClassID) (StyleOverride, bool) { - if c == testPointClassExtended { - r := 6.0 - return StyleOverride{PointRadiusPx: &r}, true - } - return StyleOverride{}, false -} -func (classThemeA) LineClassOverride(LineClassID) (StyleOverride, bool) { - return StyleOverride{}, false -} -func (classThemeA) CircleClassOverride(c CircleClassID) (StyleOverride, bool) { - if c == testCircleClassExtended { - w := 3.0 - return StyleOverride{StrokeWidthPx: &w}, true - } - return StyleOverride{}, false -} - -type classThemeB struct{ classThemeA } - -func (classThemeB) ID() string { return "classB" } -func (classThemeB) Name() string { return "classB" } -func (classThemeB) PointStyle() Style { - return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 3} -} -func (classThemeB) CircleStyle() Style { - return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 2} -} - -func (classThemeB) PointClassOverride(c PointClassID) (StyleOverride, bool) { - if c == testPointClassExtended { - r := 9.0 - return StyleOverride{PointRadiusPx: &r}, true - } - return StyleOverride{}, false -} - -// TestThemeClassOverride_AppliesAndUpdatesOnThemeChange verifies theme Class Override Applies And Updates On Theme Change. -func TestThemeClassOverride_AppliesAndUpdatesOnThemeChange(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(classThemeA{}) - - id, err := w.AddPoint(1, 1, PointWithClass(testPointClassExtended)) - require.NoError(t, err) - - p := w.objects[id].(Point) - s1, ok := w.styles.Get(p.StyleID) - require.True(t, ok) - require.Equal(t, 6.0, s1.PointRadiusPx) - - w.SetTheme(classThemeB{}) - - p2 := w.objects[id].(Point) - s2, ok := w.styles.Get(p2.StyleID) - require.True(t, ok) - require.Equal(t, 9.0, s2.PointRadiusPx) -} - -// TestThemeClassOverride_MergesWithUserOverride_UserWins verifies theme Class Override Merges With User Override User Wins. -func TestThemeClassOverride_MergesWithUserOverride_UserWins(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(classThemeA{}) - - // class would set point radius to 6, but user override sets it to 12. - rUser := 12.0 - id, err := w.AddPoint(1, 1, - PointWithClass(testPointClassExtended), - PointWithStyleOverride(StyleOverride{PointRadiusPx: &rUser}), - ) - require.NoError(t, err) - - p := w.objects[id].(Point) - s1, ok := w.styles.Get(p.StyleID) - require.True(t, ok) - require.Equal(t, 12.0, s1.PointRadiusPx) - - // After theme change, class would set to 9, but user override must still win. - w.SetTheme(classThemeB{}) - p2 := w.objects[id].(Point) - s2, ok := w.styles.Get(p2.StyleID) - require.True(t, ok) - require.Equal(t, 12.0, s2.PointRadiusPx) -} - -type themeA struct{} - -func (themeA) ID() string { return "A" } -func (themeA) Name() string { return "A" } -func (themeA) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (themeA) BackgroundImage() image.Image { return nil } -func (themeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (themeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (themeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } -func (themeA) PointStyle() Style { - return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2} -} -func (themeA) LineStyle() Style { - return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1} -} -func (themeA) CircleStyle() Style { - return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} -} -func (themeA) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (themeA) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (themeA) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } - -type themeB struct{} - -func (themeB) ID() string { return "B" } -func (themeB) Name() string { return "B" } -func (themeB) BackgroundColor() color.Color { return color.RGBA{A: 255} } -func (themeB) BackgroundImage() image.Image { return nil } -func (themeB) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } -func (themeB) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } -func (themeB) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } -func (themeB) PointStyle() Style { - return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 5} -} -func (themeB) LineStyle() Style { - return Style{StrokeColor: color.RGBA{G: 99, A: 255}, StrokeWidthPx: 3} -} -func (themeB) CircleStyle() Style { - return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 4} -} -func (themeB) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (themeB) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } -func (themeB) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } - -// TestThemeChange_UpdatesThemeDefaultStyleObjects verifies theme Change Updates Theme Default Style Objects. -func TestThemeChange_UpdatesThemeDefaultStyleObjects(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(themeA{}) - - id, err := w.AddPoint(1, 1) // default => theme-managed - require.NoError(t, err) - - p := w.objects[id].(Point) - styleBefore := p.StyleID - - w.SetTheme(themeB{}) - - p2 := w.objects[id].(Point) - styleAfter := p2.StyleID - - require.NotEqual(t, styleBefore, styleAfter) - - s, ok := w.styles.Get(styleAfter) - require.True(t, ok) - // From themeB point style - require.Equal(t, 5.0, s.PointRadiusPx) -} - -// TestThemeChange_UpdatesThemeRelativeOverride verifies theme Change Updates Theme Relative Override. -func TestThemeChange_UpdatesThemeRelativeOverride(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(themeA{}) - - white := color.RGBA{R: 255, G: 255, B: 255, A: 255} - ov := StyleOverride{StrokeColor: white} - - id, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov)) - require.NoError(t, err) - - c1 := w.objects[id].(Circle) - s1, ok := w.styles.Get(c1.StyleID) - require.True(t, ok) - - // Stroke overridden to white, fill from themeA (B=10). - require.Equal(t, uint32(0xffff), alphaOf(s1.StrokeColor)) - require.Equal(t, u16FromU8(10), blueOf(s1.FillColor)) - - w.SetTheme(themeB{}) - - c2 := w.objects[id].(Circle) - s2, ok := w.styles.Get(c2.StyleID) - require.True(t, ok) - - // Still white stroke, but fill should now come from themeB (B=99). - require.Equal(t, uint32(0xffff), alphaOf(s2.StrokeColor)) - require.Equal(t, u16FromU8(99), blueOf(s2.FillColor)) -} - -// TestThemeChange_DoesNotAffectFixedStyleID verifies theme Change Does Not Affect Fixed Style ID. -func TestThemeChange_DoesNotAffectFixedStyleID(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.SetTheme(themeA{}) - - sw := 2.0 - fixed := w.AddStyleCircle(StyleOverride{ - FillColor: color.RGBA{A: 0}, - StrokeColor: color.RGBA{R: 1, A: 255}, - StrokeWidthPx: &sw, - }) - - id, err := w.AddCircle(5, 5, 2, CircleWithStyleID(fixed)) - require.NoError(t, err) - - c1 := w.objects[id].(Circle) - require.Equal(t, fixed, c1.StyleID) - - w.SetTheme(themeB{}) - - c2 := w.objects[id].(Circle) - require.Equal(t, fixed, c2.StyleID) -} - -func alphaOf(c color.Color) uint32 { - _, _, _, a := c.RGBA() - return a -} -func blueOf(c color.Color) uint32 { - _, _, b, _ := c.RGBA() - return b -} - -// u16FromU8 converts an 8-bit channel value to the 16-bit value returned by color.Color.RGBA(). -// The standard conversion is v * 257 (0x0101) so that 0xAB becomes 0xABAB. -func u16FromU8(v uint8) uint32 { - return uint32(v) * 257 -} diff --git a/client/world/util.go b/client/world/util.go deleted file mode 100644 index 15ec10b..0000000 --- a/client/world/util.go +++ /dev/null @@ -1,1432 +0,0 @@ -package world - -import ( - "errors" - "fmt" - "hash" - "hash/fnv" - "image/color" - "math" - "math/bits" -) - -var ( - errInvalidCameraZoom = errors.New("invalid camera zoom") -) - -const ( - // SCALE is the fixed-point multiplier used across the package. - // A real value of 1.0 is represented as SCALE. - SCALE = 1000 - - // MIN_ZOOM and MAX_ZOOM define the supported zoom range in fixed-point form. - // They are reserved for future validation/clamping logic. - MIN_ZOOM = int(SCALE / 4) // 0.25x - MAX_ZOOM = int(SCALE * 32) // 32x - - // cellSizeMin and cellSizeMax bound the automatically selected grid cell size. - cellSizeMin = 16 * SCALE - cellSizeMax = 512 * SCALE -) - -// Rect is a half-open rectangle in fixed-point world coordinates: -// [minX, maxX) x [minY, maxY). -type Rect struct { - minX, maxX int - minY, maxY int -} - -// wrap maps value into the half-open interval [0, size). -// It supports negative input values and is used for torus coordinates. -func wrap(value, size int) int { - r := value % size - if r < 0 { - r += size - } - return r -} - -// clamp limits value to the closed interval [minValue, maxValue]. -func clamp(value, minValue, maxValue int) int { - if value < minValue { - return minValue - } - if value > maxValue { - return maxValue - } - return value -} - -// ceilDiv returns ceil(a / b) for positive integers. -func ceilDiv(a, b int) int { - return (a + b - 1) / b -} - -// floorDiv returns floor(a / b) for b > 0 and supports negative a. -func floorDiv(a, b int) int { - if b <= 0 { - panic("floorDiv: non-positive divisor") - } - - q := a / b - r := a % b - if r != 0 && a < 0 { - q-- - } - return q -} - -// fixedPoint converts a real value into the package fixed-point representation -// using nearest-integer rounding. -func fixedPoint(v float64) int { - return int(math.Round(v * SCALE)) -} - -// abs returns the absolute value of v. -func abs(v int) int { - if v < 0 { - return -v - } - return v -} - -// viewportPxToWorldFixed converts a viewport size in pixels into the visible -// world size in fixed-point coordinates for the given fixed-point zoom. -func viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp int) (int, int) { - return PixelSpanToWorldFixed(viewportWidthPx, cameraZoomFp), - PixelSpanToWorldFixed(viewportHeightPx, cameraZoomFp) -} - -// worldToCell maps a world coordinate to a grid cell index. -// The input coordinate is wrapped on the torus before the cell is computed. -// The function panics when the grid configuration is invalid. -func worldToCell(value, worldSize, cells, cellSize int) int { - if cells <= 0 || cellSize <= 0 { - panic(fmt.Sprintf("worldToCell: cells=%d cellSize=%d", cells, cellSize)) - } - - wrappedValue := wrap(value, worldSize) - cell := wrappedValue / cellSize - if cell >= cells { - cell = cells - 1 - } - return cell -} - -// splitByWrap splits a half-open rectangle by torus wrap into 1..4 rectangles -// fully contained inside [0, W) x [0, H). -func splitByWrap(W, H, minX, maxX, minY, maxY int) []Rect { - width := maxX - minX - height := maxY - minY - - if width <= 0 || height <= 0 { - return nil - } - - if width >= W { - minX = 0 - maxX = W - } - if height >= H { - minY = 0 - maxY = H - } - - type xPart struct { - minX, maxX int - } - - xParts := make([]xPart, 0, 2) - - if minX >= 0 && maxX <= W { - xParts = append(xParts, xPart{minX: minX, maxX: maxX}) - } else { - wrappedMinX := wrap(minX, W) - wrappedMaxX := wrap(maxX, W) - - if wrappedMinX < wrappedMaxX { - xParts = append(xParts, xPart{minX: wrappedMinX, maxX: wrappedMaxX}) - } else { - xParts = append(xParts, xPart{minX: wrappedMinX, maxX: W}) - if wrappedMaxX > 0 { - xParts = append(xParts, xPart{minX: 0, maxX: wrappedMaxX}) - } - } - } - - result := make([]Rect, 0, 4) - - for _, xp := range xParts { - if minY >= 0 && maxY <= H { - result = append(result, Rect{ - minX: xp.minX, maxX: xp.maxX, - minY: minY, maxY: maxY, - }) - continue - } - - wrappedMinY := wrap(minY, H) - wrappedMaxY := wrap(maxY, H) - - if wrappedMinY < wrappedMaxY { - result = append(result, Rect{ - minX: xp.minX, maxX: xp.maxX, - minY: wrappedMinY, maxY: wrappedMaxY, - }) - } else { - result = append(result, Rect{ - minX: xp.minX, maxX: xp.maxX, - minY: wrappedMinY, maxY: H, - }) - if wrappedMaxY > 0 { - result = append(result, Rect{ - minX: xp.minX, maxX: xp.maxX, - minY: 0, maxY: wrappedMaxY, - }) - } - } - } - - return result -} - -// PixelSpanToWorldFixed converts a span in pixels into a span in fixed-point -// world coordinates for the given fixed-point zoom. -func PixelSpanToWorldFixed(spanPx int, zoomFp int) int { - return (spanPx * SCALE * SCALE) / zoomFp -} - -// shortestWrappedDelta returns a canonical torus representation of the pair -// (from, to) along a single axis of length size. -// -// The resulting delta (b - a) is normalized into the half-open interval -// [-size/2, size/2). This makes the tie-case deterministic: -// when the points are exactly half a world apart, wrap is always applied in -// the direction that produces the negative delta. -func shortestWrappedDelta(from, to, size int) (a int, b int) { - a, b = from, to - - delta := to - from - half := size / 2 - - if delta >= half { - a += size - return - } - if delta < -half { - b += size - return - } - - return -} - -// 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 -// the neutral zoom level. The result is rounded to the nearest fixed-point value. -// -// 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) { - if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) { - return 0, errInvalidCameraZoom - } - - zoomFp := int(math.Round(cameraZoom * SCALE)) - if zoomFp <= 0 { - return 0, errInvalidCameraZoom - } - - return zoomFp, nil -} - -// mustCameraZoomToWorldFixed is the panic-on-error variant of -// 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) - if err != nil { - panic(err) - } - return zoomFp -} - -// 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 -} - -// clampCameraAxis clamps one camera axis for bounded-world rendering. -// -// If the visible span is larger than the world on that axis, the camera is -// forced to the world center to keep the result deterministic. -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 -} - -// 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 -} - -// worldFixedToCameraZoom converts a fixed-point zoom value back into the -// UI-facing floating-point representation where 1.0 means neutral zoom. -func worldFixedToCameraZoom(zoomFp int) float64 { - return float64(zoomFp) / float64(SCALE) -} - -// requiredZoomToFitWorld returns the minimum fixed-point zoom needed so that -// a viewport span of viewportSpanPx pixels does not exceed a world span of -// worldSpanFp fixed-point units. -// -// The result is rounded up, not down, because the fit constraint must be -// satisfied conservatively: after correction, the visible world span must -// never be larger than the actual world span. -func requiredZoomToFitWorld(viewportSpanPx, worldSpanFp int) int { - if viewportSpanPx < 0 { - panic("requiredZoomToFitWorld: negative viewport span") - } - if worldSpanFp <= 0 { - panic("requiredZoomToFitWorld: non-positive world span") - } - if viewportSpanPx == 0 { - return 0 - } - - return ceilDiv(viewportSpanPx*SCALE*SCALE, worldSpanFp) -} - -// correctCameraZoomFp corrects a fixed-point zoom value using two groups -// of constraints: -// -// 1. Fit-to-world constraints derived from viewport and world sizes. -// These have the highest priority and prevent the viewport from becoming -// larger than the world on any axis, which would otherwise expose wrap -// on the visible user area. -// -// 2. Optional UI zoom bounds [minZoomFp, maxZoomFp]. -// A zero bound means "ignore this bound". -// If fit-to-world requires a zoom larger than maxZoomFp, the fit constraint -// wins and maxZoomFp is ignored for that case. -// -// The function returns either the corrected zoom or currentZoomFp unchanged -// when no correction is required. -func correctCameraZoomFp( - currentZoomFp int, - viewportWidthPx, viewportHeightPx int, - worldWidthFp, worldHeightFp int, - minZoomFp, maxZoomFp int, -) int { - if currentZoomFp <= 0 { - panic("correctCameraZoomFp: non-positive current zoom") - } - if viewportWidthPx < 0 || viewportHeightPx < 0 { - panic("correctCameraZoomFp: negative viewport size") - } - if worldWidthFp <= 0 || worldHeightFp <= 0 { - panic("correctCameraZoomFp: non-positive world size") - } - if minZoomFp < 0 || maxZoomFp < 0 { - panic("correctCameraZoomFp: negative zoom bound") - } - if minZoomFp > 0 && maxZoomFp > 0 && minZoomFp > maxZoomFp { - panic("correctCameraZoomFp: min zoom greater than max zoom") - } - - // Start from the user zoom. - result := currentZoomFp - - // Apply min bound first (only increases zoom, always valid). - if minZoomFp > 0 && result < minZoomFp { - result = minZoomFp - } - - // Apply max bound tentatively. This can be overridden later by the anti-wrap constraint. - if maxZoomFp > 0 && result > maxZoomFp { - result = maxZoomFp - } - - // If viewport is larger than the world on any axis at the current result zoom, - // increase zoom to the minimum value that prevents wrap in the visible area. - requiredFitX := requiredZoomToFitWorld(viewportWidthPx, worldWidthFp) - requiredFitY := requiredZoomToFitWorld(viewportHeightPx, worldHeightFp) - requiredFit := max(requiredFitX, requiredFitY) - - if requiredFit > 0 && result < requiredFit { - result = requiredFit - } - - // Re-apply max bound only if it does not conflict with the anti-wrap requirement. - // If anti-wrap requires zoom > maxZoomFp, anti-wrap wins. - if maxZoomFp > 0 && result > maxZoomFp && requiredFit <= maxZoomFp { - result = maxZoomFp - } - - return result -} - -// CorrectCameraZoom adapts fixed-point zoom correction for UI code. -// -// currentZoom is the user-facing zoom multiplier in floating-point form. -// The result is returned in the same representation. -func (w *World) CorrectCameraZoom( - currentZoom float64, - viewportWidthPx int, - viewportHeightPx int, -) float64 { - currentZoomFp := mustCameraZoomToWorldFixed(currentZoom) - correctedZoomFp := correctCameraZoomFp( - currentZoomFp, - viewportWidthPx, - viewportHeightPx, - w.W, - w.H, - MIN_ZOOM, - MAX_ZOOM, - ) - - return worldFixedToCameraZoom(correctedZoomFp) -} - -// u128 is an unsigned 128-bit integer for safe squared comparisons. -type u128 struct{ hi, lo uint64 } - -// u128FromMul64 returns the full 128-bit product of two uint64 values. -func u128FromMul64(a, b uint64) u128 { - hi, lo := bits.Mul64(a, b) - return u128{hi: hi, lo: lo} -} - -// u128Add returns the 128-bit sum a+b. -func u128Add(a, b u128) u128 { - lo := a.lo + b.lo - hi := a.hi + b.hi - if lo < a.lo { - hi++ - } - return u128{hi: hi, lo: lo} -} - -// u128Cmp compares two unsigned 128-bit values. -func u128Cmp(a, b u128) int { - if a.hi < b.hi { - return -1 - } - if a.hi > b.hi { - return 1 - } - if a.lo < b.lo { - return -1 - } - if a.lo > b.lo { - return 1 - } - return 0 -} - -// abs64 returns the absolute value of x. -func abs64(x int64) int64 { - if x < 0 { - return -x - } - return x -} - -// sqU128Int64 returns x*x as an unsigned 128-bit value. -func sqU128Int64(x int64) u128 { - u := uint64(abs64(x)) - return u128FromMul64(u, u) -} - -// distSqU128 returns dx*dx + dy*dy as an unsigned 128-bit value. -func distSqU128(dx, dy int64) u128 { - return u128Add(sqU128Int64(dx), sqU128Int64(dy)) -} - -// shortestTorusDelta returns the shortest signed delta from a->b on a torus axis of size. -// It is deterministic in tie cases (size even, exactly half): chooses negative direction. -func shortestTorusDelta(a, b, size int) int64 { - d := int64(b - a) - s := int64(size) - half := s / 2 - - // Normalize d into (-s, s). - d = d % s - if d <= -half { - d += s - } else if d > half { - d -= s - } - - // Tie case when size even and d == +half: choose -half. - if s%2 == 0 && d == half { - d = -half - } - return d -} - -// effectiveHitSlopPx resolves the per-primitive hit slop, falling back to def -// when the primitive does not override it explicitly. -func effectiveHitSlopPx(hitSlopPx int, def int) int { - if hitSlopPx > 0 { - return hitSlopPx - } - return def -} - -// alphaNonZero reports whether c is non-nil and has a non-zero alpha channel. -func alphaNonZero(c color.Color) bool { - if c == nil { - return false - } - _, _, _, a := c.RGBA() - return a != 0 -} - -// hitPoint performs point hit testing in world-fixed coordinates. -func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { - slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx) - slopW := PixelSpanToWorldFixed(slopPx, zoomFp) - - var dx, dy int64 - if allowWrap { - dx = shortestTorusDelta(p.X, cx, worldW) - dy = shortestTorusDelta(p.Y, cy, worldH) - } else { - dx = int64(cx - p.X) - dy = int64(cy - p.Y) - } - - // Point is treated as a small disc: dist <= slop. - ds := distSqU128(dx, dy) - rs := sqU128Int64(int64(slopW)) - - if u128Cmp(ds, rs) <= 0 { - return Hit{ - ID: p.Id, - Kind: KindPoint, - Priority: p.Priority, - StyleID: p.StyleID, - DistanceSq: ds, - X: p.X, - Y: p.Y, - }, true - } - return Hit{}, false -} - -// hitCircle performs circle hit testing in world-fixed coordinates, including -// fill-vs-stroke semantics and minimum point-like radius handling. -func hitCircle(c Circle, effRadiusFp int, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { - slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx) - slopW := PixelSpanToWorldFixed(slopPx, zoomFp) - - fillVisible := alphaNonZero(style.FillColor) - - // Determine if circle is point-like at current zoom. - // IMPORTANT: point-like disc behavior applies only for filled circles. - rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp) - pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx - - var dx, dy int64 - if allowWrap { - dx = shortestTorusDelta(c.X, cx, worldW) - dy = shortestTorusDelta(c.Y, cy, worldH) - } else { - dx = int64(cx - c.X) - dy = int64(cy - c.Y) - } - - ds := distSqU128(dx, dy) - - // Filled + point-like: treat as a disc with minimum visible radius + slop. - if pointLike { - // Treat as a disc with minimum visible radius in px. - minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) - effR := minRW - if effRadiusFp > effR { - effR = effRadiusFp - } - r := effR + slopW - if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 { - return Hit{ - ID: c.Id, - Kind: KindCircle, - Priority: c.Priority, - StyleID: c.StyleID, - DistanceSq: ds, - X: c.X, - Y: c.Y, - Radius: effRadiusFp, - }, true - } - return Hit{}, false - } - - // Filled circle: hit-test by disc (surface). - if fillVisible { - r := effRadiusFp + slopW - if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 { - return Hit{ - ID: c.Id, - Kind: KindCircle, - Priority: c.Priority, - StyleID: c.StyleID, - DistanceSq: ds, - X: c.X, - Y: c.Y, - Radius: effRadiusFp, - }, true - } - return Hit{}, false - } - - // Stroke-only circle: ring hit, but NEVER at exact center. - // For very small circles, expand the effective radius to a minimum visible size - // so that ring selection remains practical, while still excluding center. - effR := effRadiusFp - if rPx < CirclePointLikeMinRadiusPx { - minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) - if minRW > effR { - effR = minRW - } - } - - low := effR - slopW - // IMPORTANT: center must not hit for stroke-only circles. - if low < 1 { - low = 1 - } - high := effR + slopW - - lowSq := sqU128Int64(int64(low)) - highSq := sqU128Int64(int64(high)) - - if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 { - return Hit{ - ID: c.Id, - Kind: KindCircle, - Priority: c.Priority, - StyleID: c.StyleID, - DistanceSq: ds, - X: c.X, - Y: c.Y, - Radius: effRadiusFp, - }, true - } - return Hit{}, false -} - -// hitLine performs line hit testing against the torus-shortest segment set used -// by the renderer. -func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { - slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx) - slopW := PixelSpanToWorldFixed(slopPx, zoomFp) - - // For wrap: compare against torus-shortest representation (same as rendering). - // We test all segments produced by torusShortestLineSegments and take the best (min distance). - segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}} - if allowWrap { - segs = torusShortestLineSegments(l, worldW, worldH) - } - - best := Hit{} - found := false - - for _, s := range segs { - ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2)) - - // Check ds <= slopW^2 - if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 { - h := Hit{ - ID: l.Id, - Kind: KindLine, - Priority: l.Priority, - StyleID: l.StyleID, - DistanceSq: ds, - X1: l.X1, - Y1: l.Y1, - X2: l.X2, - Y2: l.Y2, - } - if !found || hitLess(h, best) { - best = h - found = true - } - } - } - - return best, found -} - -// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons. -func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 { - abx := bx - ax - aby := by - ay - apx := px - ax - apy := py - ay - - // Degenerate segment => distance to point A. - if abx == 0 && aby == 0 { - return distSqU128(apx, apy) - } - - dot := apx*abx + apy*aby - if dot <= 0 { - return distSqU128(apx, apy) - } - - abLen2 := abx*abx + aby*aby - if dot >= abLen2 { - bpx := px - bx - bpy := py - by - return distSqU128(bpx, bpy) - } - - // Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers. - // Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain - // would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use - // dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64. - // - // cross = AP x AB - cross := apx*aby - apy*abx - - // cross^2 fits in u128, abLen2 fits in int64. - c2 := sqU128Int64(cross) - return u128DivByU64(c2, uint64(abLen2)) -} - -// u128DivByU64 returns floor(a / d) where d>0, producing u128 result. -// Here we only need it for tie-breaking (monotonic). -func u128DivByU64(a u128, d uint64) u128 { - if d == 0 { - panic("u128DivByU64: divide by zero") - } - // Simple long division for 128/64 -> 128 quotient (but high part will be small here). - // We compute using two-step: divide high then combine. - qHi := a.hi / d - rHi := a.hi % d - - // Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d. - // Use bits.Div64 for (hi, lo)/d. - qLo, _ := bits.Div64(rHi, a.lo, d) - - return u128{hi: qHi, lo: qLo} -} - -// torusShortestLineSegmentsInto converts a Line primitive into 1..4 canonical segments -// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline. -// -// It appends results into dst using tmp as an intermediate buffer. -// No allocations occur if dst/tmp have sufficient capacity (>=4). -func torusShortestLineSegmentsInto(dst, tmp []lineSeg, l Line, worldW, worldH int) ([]lineSeg, []lineSeg) { - dst = dst[:0] - tmp = tmp[:0] - - // Step 1: choose the torus-shortest representation in unwrapped space. - ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW) - ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH) - - // Step 2: shift so that A is inside canonical [0..W) x [0..H). - shiftX := floorDiv(ax, worldW) * worldW - shiftY := floorDiv(ay, worldH) * worldH - - ax -= shiftX - bx -= shiftX - ay -= shiftY - by -= shiftY - - dst = append(dst, lineSeg{x1: ax, y1: ay, x2: bx, y2: by}) - - // Step 3: split by X boundary if needed (jump-aware). - tmp = splitSegmentsByXInto(tmp, dst, worldW) - - // Step 4: split by Y boundary if needed (jump-aware). - dst = splitSegmentsByYInto(dst, tmp, worldH) - - return dst, tmp -} - -// torusShortestLineSegments is a compatibility wrapper that allocates. -// Prefer torusShortestLineSegmentsInto in hot paths. -func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg { - dst := make([]lineSeg, 0, 4) - tmp := make([]lineSeg, 0, 4) - dst, _ = torusShortestLineSegmentsInto(dst, tmp, l, worldW, worldH) - return dst -} - -// splitSegmentsByXInto appends 1..2 segments for each input segment into out, without allocating. -// out is reset to length 0 by this function. -func splitSegmentsByXInto(out []lineSeg, segs []lineSeg, worldW int) []lineSeg { - out = out[:0] - - for _, s := range segs { - x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 - - // After normalization, x1 is expected inside [0..worldW). Only x2 may be outside. - if x2 >= 0 && x2 < worldW { - out = append(out, s) - continue - } - - dx := x2 - x1 - dy := y2 - y1 - if dx == 0 { - out = append(out, s) - continue - } - - if x2 >= worldW { - // Crosses the right boundary at x=worldW, then reappears at x=0. - bx := worldW - num := bx - x1 - iy := y1 + (dy*num)/dx - - s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy} - s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2} - out = append(out, s1, s2) - continue - } - - // x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW. - bx := 0 - num := bx - x1 - iy := y1 + (dy*num)/dx - - s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy} - s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2} - out = append(out, s1, s2) - } - - return out -} - -// splitSegmentsByYInto appends 1..2 segments for each input segment into out, without allocating. -// out is reset to length 0 by this function. -func splitSegmentsByYInto(out []lineSeg, segs []lineSeg, worldH int) []lineSeg { - out = out[:0] - - for _, s := range segs { - x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 - - // After normalization, y1 is expected inside [0..worldH). Only y2 may be outside. - if y2 >= 0 && y2 < worldH { - out = append(out, s) - continue - } - - dx := x2 - x1 - dy := y2 - y1 - if dy == 0 { - out = append(out, s) - continue - } - - if y2 >= worldH { - // Crosses the top boundary at y=worldH, then reappears at y=0. - by := worldH - num := by - y1 - ix := x1 + (dx*num)/dy - - s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH} - s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH} - out = append(out, s1, s2) - continue - } - - // y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH. - by := 0 - num := by - y1 - ix := x1 + (dx*num)/dy - - s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0} - s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH} - out = append(out, s1, s2) - } - - return out -} - -// mergeOverrides applies userOv on top of classOv, preserving the "nil means -// not specified" semantics used by StyleOverride fields. -func mergeOverrides(classOv, userOv StyleOverride) StyleOverride { - out := classOv - - // Colors: nil means "unset" - if userOv.FillColor != nil { - out.FillColor = userOv.FillColor - } - if userOv.StrokeColor != nil { - out.StrokeColor = userOv.StrokeColor - } - - // Pointers: nil means "unset" - if userOv.StrokeWidthPx != nil { - out.StrokeWidthPx = userOv.StrokeWidthPx - } - if userOv.StrokeDashes != nil { - out.StrokeDashes = userOv.StrokeDashes - } - if userOv.StrokeDashOffset != nil { - out.StrokeDashOffset = userOv.StrokeDashOffset - } - if userOv.PointRadiusPx != nil { - out.PointRadiusPx = userOv.PointRadiusPx - } - - return out -} - -// hashU64 writes v to the hash in little-endian form. -// We keep it manual to avoid extra allocations and dependencies. -func hashU64(h hash.Hash64, v uint64) { - var b [8]byte - b[0] = byte(v) - b[1] = byte(v >> 8) - b[2] = byte(v >> 16) - b[3] = byte(v >> 24) - b[4] = byte(v >> 32) - b[5] = byte(v >> 40) - b[6] = byte(v >> 48) - b[7] = byte(v >> 56) - _, _ = h.Write(b[:]) -} - -// hashBool writes a boolean value to the fingerprint stream. -func hashBool(h hash.Hash64, v bool) { - if v { - hashU64(h, 1) - } else { - hashU64(h, 0) - } -} - -// hashColor writes a color value to the fingerprint stream. -func hashColor(h hash.Hash64, c color.Color) { - if c == nil { - hashU64(h, 0) - return - } - r, g, b, a := c.RGBA() - hashU64(h, uint64(r)) - hashU64(h, uint64(g)) - hashU64(h, uint64(b)) - hashU64(h, uint64(a)) -} - -// fingerprint returns a stable hash of the override content. -// -// Notes on semantics: -// - FillColor / StrokeColor: nil means "unset" (do not override). Transparent override is represented -// by a non-nil color with alpha=0. -// - Pointer fields (*float64, *[]float64) encode presence via nil/non-nil. -// - StrokeDashes: nil pointer means "unset"; non-nil pointer to nil slice means "set to nil". -func (o StyleOverride) fingerprint() uint64 { - h := fnv.New64a() // returns hash.Hash64 - - // FillColor / StrokeColor - hashBool(h, o.FillColor != nil) - hashColor(h, o.FillColor) - - hashBool(h, o.StrokeColor != nil) - hashColor(h, o.StrokeColor) - - // StrokeWidthPx - hashBool(h, o.StrokeWidthPx != nil) - if o.StrokeWidthPx != nil { - hashU64(h, math.Float64bits(*o.StrokeWidthPx)) - } - - // StrokeDashes - hashBool(h, o.StrokeDashes != nil) - if o.StrokeDashes != nil { - ds := *o.StrokeDashes - if ds == nil { - // Explicitly set to nil slice - hashU64(h, 0xffffffffffffffff) - } else { - hashU64(h, uint64(len(ds))) - for _, v := range ds { - hashU64(h, math.Float64bits(v)) - } - } - } - - // StrokeDashOffset - hashBool(h, o.StrokeDashOffset != nil) - if o.StrokeDashOffset != nil { - hashU64(h, math.Float64bits(*o.StrokeDashOffset)) - } - - // PointRadiusPx - hashBool(h, o.PointRadiusPx != nil) - if o.PointRadiusPx != nil { - hashU64(h, math.Float64bits(*o.PointRadiusPx)) - } - - return h.Sum64() -} - -// drawPointsFromPlan keeps backward compatibility for older tests/helpers. -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, 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, allowWrap bool) { - // Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed. - rPxInt := int(math.Ceil(radiusPx)) - if rPxInt < 0 { - rPxInt = 0 - } - rWorldFp := 0 - if rPxInt > 0 { - rWorldFp = PixelSpanToWorldFixed(rPxInt, plan.ZoomFp) - } - - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - points := make([]Point, 0, len(td.Candidates)) - for _, it := range td.Candidates { - p, ok := it.(Point) - if !ok { - continue - } - points = append(points, p) - } - if len(points) == 0 { - continue - } - - type pointCopy struct { - p Point - dx int - dy int - } - copiesToDraw := make([]pointCopy, 0, len(points)) - - for _, p := range points { - 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}) - } - } - } - - if len(copiesToDraw) == 0 { - continue - } - - drawer.Save() - drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) - - for _, pc := range copiesToDraw { - p := pc.p - - px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+pc.dx)-plan.WorldRect.minX, plan.ZoomFp) - py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+pc.dy)-plan.WorldRect.minY, plan.ZoomFp) - - drawer.AddPoint(float64(px), float64(py), radiusPx) - } - - drawer.Fill() - drawer.Restore() - } -} - -// pointWrapShifts returns the torus-copy offsets required for a point marker -// whose visible disc may cross world edges. -func pointWrapShifts(p Point, rWorldFp, worldW, worldH int) []wrapShift { - // If world sizes are unknown, do not generate wrap copies. - if worldW <= 0 || worldH <= 0 { - return []wrapShift{{dx: 0, dy: 0}} - } - - xShifts := []int{0} - yShifts := []int{0} - - if p.X+rWorldFp >= worldW { - xShifts = append(xShifts, -worldW) - } - if p.X-rWorldFp < 0 { - xShifts = append(xShifts, worldW) - } - - if p.Y+rWorldFp >= worldH { - yShifts = append(yShifts, -worldH) - } - if p.Y-rWorldFp < 0 { - yShifts = append(yShifts, worldH) - } - - out := make([]wrapShift, 0, len(xShifts)*len(yShifts)) - for _, dx := range xShifts { - for _, dy := range yShifts { - out = append(out, wrapShift{dx: dx, dy: dy}) - } - } - return out -} - -// pointCopyIntersectsTile reports whether a particular wrapped point copy can -// contribute pixels inside the given world tile. -func pointCopyIntersectsTile(p Point, rWorldFp, dx, dy int, tile WorldTile) bool { - segMinX := tile.OffsetX + tile.Rect.minX - segMaxX := tile.OffsetX + tile.Rect.maxX - segMinY := tile.OffsetY + tile.Rect.minY - segMaxY := tile.OffsetY + tile.Rect.maxY - - px := p.X + tile.OffsetX + dx - py := p.Y + tile.OffsetY + dy - - minX := px - rWorldFp - maxX := px + rWorldFp - minY := py - rWorldFp - maxY := py + rWorldFp - - if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY { - return false - } - return true -} - -// drawCirclesFromPlan executes a circles-only draw from an already built render plan. -func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, allowWrap bool, circleRadiusScaleFp int) { - for _, td := range plan.Tiles { - if td.ClipW <= 0 || td.ClipH <= 0 { - continue - } - - // Filter only circles; skip tiles that have no circles. - circles := make([]Circle, 0, len(td.Candidates)) - for _, it := range td.Candidates { - c, ok := it.(Circle) - if !ok { - continue - } - circles = append(circles, c) - } - if len(circles) == 0 { - continue - } - - // Determine which circle copies actually intersect this tile segment. - type circleCopy struct { - c Circle - dx int - dy int - } - copiesToDraw := make([]circleCopy, 0, len(circles)) - - for _, c := range circles { - var shifts []wrapShift - effRadius := circleRadiusEffFp(c.Radius, circleRadiusScaleFp) - if allowWrap { - shifts = circleWrapShifts(c.X, c.Y, effRadius, worldW, worldH) - } else { - shifts = []wrapShift{{dx: 0, dy: 0}} - } - for _, s := range shifts { - if circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, worldW, worldH) { - copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy}) - } - } - } - - if len(copiesToDraw) == 0 { - continue - } - - drawer.Save() - drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) - - for _, cc := range copiesToDraw { - c := cc.c - - // Project the circle center for this tile copy (tile offset + wrap shift). - cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+cc.dx)-plan.WorldRect.minX, plan.ZoomFp) - cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+cc.dy)-plan.WorldRect.minY, plan.ZoomFp) - - // Radius is a world span. - rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp) - - drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - } - - drawer.Fill() - drawer.Restore() - } -} - -// wrapShift stores one torus-copy offset in world-fixed coordinates. -type wrapShift struct { - dx int - dy int -} - -// circleWrapShiftsInto appends required torus-copy shifts for a circle into dst and returns the resulting slice. -// It never allocates if dst has enough capacity. -// -// The 0-shift is always included. Additional copies are included when the circle's bbox crosses world edges. -func circleWrapShiftsInto(dst []wrapShift, cx, cy, radiusFp, worldW, worldH int) []wrapShift { - dst = dst[:0] - - // Always include the original. - dst = append(dst, wrapShift{dx: 0, dy: 0}) - - if radiusFp <= 0 { - return dst - } - - minX := cx - radiusFp - maxX := cx + radiusFp - minY := cy - radiusFp - maxY := cy + radiusFp - - needLeft := minX < 0 - needRight := maxX > worldW - needTop := minY < 0 - needBottom := maxY > worldH - - // X-only copies. - if needLeft { - dst = append(dst, wrapShift{dx: +worldW, dy: 0}) - } - if needRight { - dst = append(dst, wrapShift{dx: -worldW, dy: 0}) - } - - // Y-only copies. - if needTop { - dst = append(dst, wrapShift{dx: 0, dy: +worldH}) - } - if needBottom { - dst = append(dst, wrapShift{dx: 0, dy: -worldH}) - } - - // Corner copies (combine X and Y). - if (needLeft || needRight) && (needTop || needBottom) { - var dxs [2]int - dxn := 0 - if needLeft { - dxs[dxn] = +worldW - dxn++ - } - if needRight { - dxs[dxn] = -worldW - dxn++ - } - - var dys [2]int - dyn := 0 - if needTop { - dys[dyn] = +worldH - dyn++ - } - if needBottom { - dys[dyn] = -worldH - dyn++ - } - - for i := 0; i < dxn; i++ { - for j := 0; j < dyn; j++ { - dst = append(dst, wrapShift{dx: dxs[i], dy: dys[j]}) - } - } - } - - return dst -} - -// circleWrapShifts is a compatibility wrapper that allocates. -// Prefer circleWrapShiftsInto in hot paths. -func circleWrapShifts(cx, cy, radiusFp, worldW, worldH int) []wrapShift { - var dst []wrapShift - return circleWrapShiftsInto(dst, cx, cy, radiusFp, worldW, worldH) -} - -// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment. -// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis. -func circleCopyIntersectsTile(cx, cy, radiusFp, dx, dy int, tile WorldTile, worldW, worldH int) bool { - // Unwrapped tile segment bounds. - segMinX := tile.OffsetX + tile.Rect.minX - segMaxX := tile.OffsetX + tile.Rect.maxX - segMinY := tile.OffsetY + tile.Rect.minY - segMaxY := tile.OffsetY + tile.Rect.maxY - - // Circle bbox in the same unwrapped space (apply shift + tile offset). - cx = cx + tile.OffsetX + dx - cy = cy + tile.OffsetY + dy - - minX := cx - radiusFp - maxX := cx + radiusFp - minY := cy - radiusFp - maxY := cy + radiusFp - - // Treat bbox as half-open for intersection checks. - if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY { - return false - } - return true -} diff --git a/client/world/util_test.go b/client/world/util_test.go deleted file mode 100644 index f09a07d..0000000 --- a/client/world/util_test.go +++ /dev/null @@ -1,969 +0,0 @@ -package world - -import ( - "github.com/stretchr/testify/require" - "math" - "testing" -) - -// TestWrap verifies wrap. -func TestWrap(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value int - size int - want int - }{ - {name: "zero", value: 0, size: 10, want: 0}, - {name: "inside range", value: 7, size: 10, want: 7}, - {name: "equal to size", value: 10, size: 10, want: 0}, - {name: "greater than size", value: 27, size: 10, want: 7}, - {name: "negative one", value: -1, size: 10, want: 9}, - {name: "negative many", value: -23, size: 10, want: 7}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := wrap(tt.value, tt.size); got != tt.want { - t.Fatalf("wrap(%d, %d) = %d, want %d", tt.value, tt.size, got, tt.want) - } - }) - } -} - -// TestClamp verifies clamp. -func TestClamp(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value int - minValue int - maxValue int - want int - }{ - {name: "below range", value: -5, minValue: 0, maxValue: 10, want: 0}, - {name: "inside range", value: 5, minValue: 0, maxValue: 10, want: 5}, - {name: "above range", value: 15, minValue: 0, maxValue: 10, want: 10}, - {name: "equal min", value: 0, minValue: 0, maxValue: 10, want: 0}, - {name: "equal max", value: 10, minValue: 0, maxValue: 10, want: 10}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want { - t.Fatalf("clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want) - } - }) - } -} - -// TestCeilDiv verifies ceil Div. -func TestCeilDiv(t *testing.T) { - t.Parallel() - - tests := []struct { - a int - b int - want int - }{ - {a: 1, b: 1, want: 1}, - {a: 1, b: 2, want: 1}, - {a: 2, b: 2, want: 1}, - {a: 3, b: 2, want: 2}, - {a: 10, b: 3, want: 4}, - {a: 16, b: 8, want: 2}, - {a: 17, b: 8, want: 3}, - } - - for _, tt := range tests { - if got := ceilDiv(tt.a, tt.b); got != tt.want { - t.Fatalf("ceilDiv(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) - } - } -} - -// TestFloorDiv verifies floor Div. -func TestFloorDiv(t *testing.T) { - t.Parallel() - - require.Equal(t, 0, floorDiv(0, 10)) - require.Equal(t, 0, floorDiv(1, 10)) - require.Equal(t, 0, floorDiv(9, 10)) - require.Equal(t, 1, floorDiv(10, 10)) - require.Equal(t, 1, floorDiv(19, 10)) - - require.Equal(t, -1, floorDiv(-1, 10)) - require.Equal(t, -1, floorDiv(-9, 10)) - require.Equal(t, -1, floorDiv(-10, 10)) - require.Equal(t, -2, floorDiv(-11, 10)) - - require.Panics(t, func() { _ = floorDiv(1, 0) }) - require.Panics(t, func() { _ = floorDiv(1, -1) }) -} - -// TestFixedPoint verifies fixed Point. -func TestFixedPoint(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - v float64 - want int - }{ - {name: "zero", v: 0, want: 0}, - {name: "integer", v: 3, want: 3000}, - {name: "fraction", v: 1.234, want: 1234}, - {name: "round down", v: 1.2344, want: 1234}, - {name: "round up", v: 1.2345, want: 1235}, - {name: "negative", v: -1.2345, want: -1235}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := fixedPoint(tt.v); got != tt.want { - t.Fatalf("fixedPoint(%f) = %d, want %d", tt.v, got, tt.want) - } - }) - } -} - -// TestAbs verifies abs. -func TestAbs(t *testing.T) { - t.Parallel() - - tests := []struct { - v int - want int - }{ - {v: 0, want: 0}, - {v: 7, want: 7}, - {v: -7, want: 7}, - } - - for _, tt := range tests { - if got := abs(tt.v); got != tt.want { - t.Fatalf("abs(%d) = %d, want %d", tt.v, got, tt.want) - } - } -} - -// TestPixelSpanToWorldFixed verifies pixel Span To World Fixed. -func TestPixelSpanToWorldFixed(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - spanPx int - zoomFp int - want int - }{ - {name: "1x zoom", spanPx: 100, zoomFp: SCALE, want: 100 * SCALE}, - {name: "2x zoom", spanPx: 100, zoomFp: 2 * SCALE, want: 50 * SCALE}, - {name: "half zoom", spanPx: 100, zoomFp: SCALE / 2, want: 200 * SCALE}, - {name: "fractional result truncation", spanPx: 1, zoomFp: 3 * SCALE, want: 333}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := PixelSpanToWorldFixed(tt.spanPx, tt.zoomFp); got != tt.want { - t.Fatalf("PixelSpanToWorldFixed(%d, %d) = %d, want %d", tt.spanPx, tt.zoomFp, got, tt.want) - } - }) - } -} - -// TestWorldToCellPanicsOnInvalidGrid verifies world To Cell Panics On Invalid Grid. -func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cells int - cellSize int - }{ - {name: "zero cells", cells: 0, cellSize: 1}, - {name: "negative cells", cells: -1, cellSize: 1}, - {name: "zero cell size", cells: 1, cellSize: 0}, - {name: "negative cell size", cells: 1, cellSize: -1}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - defer func() { - if recover() == nil { - t.Fatalf("worldToCell did not panic for cells=%d cellSize=%d", tt.cells, tt.cellSize) - } - }() - - _ = worldToCell(0, 1000, tt.cells, tt.cellSize) - }) - } -} - -// TestShortestWrappedDelta verifies shortest Wrapped Delta. -func TestShortestWrappedDelta(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - from int - to int - size int - wantA int - wantB int - }{ - {name: "no wrap forward", from: 1000, to: 3000, size: 10000, wantA: 1000, wantB: 3000}, - {name: "no wrap backward", from: 3000, to: 1000, size: 10000, wantA: 3000, wantB: 1000}, - {name: "wrap forward over half", from: 1000, to: 7000, size: 10000, wantA: 11000, wantB: 7000}, - {name: "wrap backward over half", from: 7000, to: 1000, size: 10000, wantA: 7000, wantB: 11000}, - {name: "tie positive half wraps", from: 1000, to: 6000, size: 10000, wantA: 11000, wantB: 6000}, - {name: "tie negative half stays", from: 6000, to: 1000, size: 10000, wantA: 6000, wantB: 1000}, - {name: "just below positive half does not wrap", from: 1000, to: 5999, size: 10000, wantA: 1000, wantB: 5999}, - {name: "just beyond negative half wraps", from: 6001, to: 1000, size: 10000, wantA: 6001, wantB: 11000}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - gotA, gotB := shortestWrappedDelta(tt.from, tt.to, tt.size) - if gotA != tt.wantA || gotB != tt.wantB { - t.Fatalf("shortestWrappedDelta(%d, %d, %d) = (%d, %d), want (%d, %d)", - tt.from, tt.to, tt.size, gotA, gotB, tt.wantA, tt.wantB) - } - - delta := gotB - gotA - half := tt.size / 2 - if delta < -half || delta >= half { - t.Fatalf("normalized delta %d is outside [-%d, %d)", delta, half, half) - } - }) - } -} - -// TestCameraZoomToWorldFixed verifies camera Zoom To World Fixed. -func TestCameraZoomToWorldFixed(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cameraZoom float64 - want int - }{ - { - name: "neutral zoom", - cameraZoom: 1.0, - want: SCALE, - }, - { - name: "integer zoom", - cameraZoom: 2.0, - want: 2 * SCALE, - }, - { - name: "fractional zoom", - cameraZoom: 1.25, - want: 1250, - }, - { - name: "minimum configured zoom value shape", - cameraZoom: 0.25, - want: SCALE / 4, - }, - { - name: "round down", - cameraZoom: 1.2344, - want: 1234, - }, - { - name: "round up", - cameraZoom: 1.2345, - want: 1235, - }, - { - name: "very small but still positive after rounding", - cameraZoom: 0.0006, - want: 1, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := CameraZoomToWorldFixed(tt.cameraZoom) - require.NoError(t, err) - require.Equal(t, tt.want, got) - }) - } -} - -// TestCameraZoomToWorldFixedReturnsError verifies camera Zoom To World Fixed Returns Error. -func TestCameraZoomToWorldFixedReturnsError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cameraZoom float64 - }{ - { - name: "zero", - cameraZoom: 0, - }, - { - name: "negative", - cameraZoom: -1, - }, - { - name: "nan", - cameraZoom: math.NaN(), - }, - { - name: "positive infinity", - cameraZoom: math.Inf(1), - }, - { - name: "negative infinity", - cameraZoom: math.Inf(-1), - }, - { - name: "positive but rounds to zero", - cameraZoom: 0.0004, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := CameraZoomToWorldFixed(tt.cameraZoom) - require.ErrorIs(t, err, errInvalidCameraZoom) - require.Zero(t, got) - }) - } -} - -// TestMustCameraZoomToWorldFixed verifies must Camera Zoom To World Fixed. -func TestMustCameraZoomToWorldFixed(t *testing.T) { - t.Parallel() - - require.Equal(t, 1250, mustCameraZoomToWorldFixed(1.25)) - - require.Panics(t, func() { - _ = mustCameraZoomToWorldFixed(0) - }) -} - -// TestWorldFixedToCameraZoom verifies world Fixed To Camera Zoom. -func TestWorldFixedToCameraZoom(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - zoomFp int - want float64 - }{ - {name: "zero", zoomFp: 0, want: 0}, - {name: "neutral", zoomFp: SCALE, want: 1.0}, - {name: "fractional", zoomFp: 1250, want: 1.25}, - {name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := worldFixedToCameraZoom(tt.zoomFp) - require.Equal(t, tt.want, got) - }) - } -} - -// TestRequiredZoomToFitWorld verifies required Zoom To Fit World. -func TestRequiredZoomToFitWorld(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - viewportSpanPx int - worldSpanFp int - want int - }{ - { - name: "zero viewport span", - viewportSpanPx: 0, - worldSpanFp: 10 * SCALE, - want: 0, - }, - { - name: "exact neutral fit", - viewportSpanPx: 10, - worldSpanFp: 10 * SCALE, - want: SCALE, - }, - { - name: "exact 2x fit", - viewportSpanPx: 20, - worldSpanFp: 10 * SCALE, - want: 2 * SCALE, - }, - { - name: "fractional fit rounded up", - viewportSpanPx: 11, - worldSpanFp: 10 * SCALE, - want: 1100, - }, - { - name: "small world requires larger zoom", - viewportSpanPx: 320, - worldSpanFp: 80 * SCALE, - want: 4 * SCALE, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp) - require.Equal(t, tt.want, got) - }) - } -} - -// TestRequiredZoomToFitWorldPanics verifies required Zoom To Fit World Panics. -func TestRequiredZoomToFitWorldPanics(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - viewportSpanPx int - worldSpanFp int - }{ - { - name: "negative viewport span", - viewportSpanPx: -1, - worldSpanFp: 10 * SCALE, - }, - { - name: "zero world span", - viewportSpanPx: 10, - worldSpanFp: 0, - }, - { - name: "negative world span", - viewportSpanPx: 10, - worldSpanFp: -1, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - require.Panics(t, func() { - _ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp) - }) - }) - } -} - -// TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded verifies correct Camera Zoom Fp Returns Current When No Correction Needed. -func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 2*SCALE, - 40, 30, - 100*SCALE, 100*SCALE, - MIN_ZOOM, MAX_ZOOM, - ) - - require.Equal(t, 2*SCALE, got) -} - -// TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth verifies correct Camera Zoom Fp Raises Zoom To Fit World Width. -func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 120, 20, - 100*SCALE, 100*SCALE, - 0, 0, - ) - - require.Equal(t, 1200, got) -} - -// TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight verifies correct Camera Zoom Fp Raises Zoom To Fit World Height. -func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 20, 150, - 100*SCALE, 100*SCALE, - 0, 0, - ) - - require.Equal(t, 1500, got) -} - -// TestCorrectCameraZoomFpUsesMaxFitAcrossAxes verifies correct Camera Zoom Fp Uses Max Fit Across Axes. -func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 120, 150, - 100*SCALE, 100*SCALE, - 0, 0, - ) - - require.Equal(t, 1500, got) -} - -// TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit verifies correct Camera Zoom Fp Applies Min Zoom When Larger Than Current And Fit. -func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 20, 20, - 100*SCALE, 100*SCALE, - 1500, 0, - ) - - require.Equal(t, 1500, got) -} - -// TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Fit Conflict. -func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 4*SCALE, - 20, 20, - 100*SCALE, 100*SCALE, - 0, 3*SCALE, - ) - - require.Equal(t, 3*SCALE, got) -} - -// TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore verifies correct Camera Zoom Fp Ignores Max Zoom When Fit Needs More. -func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 200, 20, - 100*SCALE, 100*SCALE, - 0, 1500, - ) - - require.Equal(t, 2*SCALE, got) -} - -// TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid verifies correct Camera Zoom Fp Applies Min Then Max When Both Valid. -func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 20, 20, - 100*SCALE, 100*SCALE, - 1500, 1600, - ) - - require.Equal(t, 1500, got) -} - -// TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped verifies correct Camera Zoom Fp Current Above Max Gets Clamped. -func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 5*SCALE, - 20, 20, - 100*SCALE, 100*SCALE, - 0, 3*SCALE, - ) - - require.Equal(t, 3*SCALE, got) -} - -// TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds. -func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 0, 0, - 100*SCALE, 100*SCALE, - 1500, 0, - ) - - require.Equal(t, 1500, got) -} - -// TestCorrectCameraZoomFpZeroBoundsAreIgnored verifies correct Camera Zoom Fp Zero Bounds Are Ignored. -func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 1250, - 20, 20, - 100*SCALE, 100*SCALE, - 0, 0, - ) - - require.Equal(t, 1250, got) -} - -// TestCorrectCameraZoomFpPanics verifies correct Camera Zoom Fp Panics. -func TestCorrectCameraZoomFpPanics(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - fn func() - }{ - { - name: "non-positive current zoom", - fn: func() { - _ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0) - }, - }, - { - name: "negative viewport width", - fn: func() { - _ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0) - }, - }, - { - name: "negative viewport height", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0) - }, - }, - { - name: "non-positive world width", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0) - }, - }, - { - name: "non-positive world height", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0) - }, - }, - { - name: "negative min zoom", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0) - }, - }, - { - name: "negative max zoom", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1) - }, - }, - { - name: "min greater than max", - fn: func() { - _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500) - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - require.Panics(t, tt.fn) - }) - } -} - -// TestWorldCorrectCameraZoomReturnsFloatValue verifies world Correct Camera Zoom Returns Float Value. -func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) { - t.Parallel() - - w := NewWorld(100, 100) - - got := w.CorrectCameraZoom(1.0, 120, 20) - - require.Equal(t, 1.2, got) -} - -// TestWorldCorrectCameraZoomAppliesDefaultBounds verifies world Correct Camera Zoom Applies Default Bounds. -func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) { - t.Parallel() - - w := NewWorld(100, 100) - - got := w.CorrectCameraZoom(100.0, 20, 20) - - require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got) -} - -// TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound verifies world Correct Camera Zoom Fit Beats Default Max Bound. -func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) { - t.Parallel() - - w := NewWorld(1, 100) - - got := w.CorrectCameraZoom(1.0, 40, 10) - - require.Equal(t, 40.0, got) -} - -// TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld verifies correct Camera Zoom Fp Does Not Lower Zoom When Viewport Is Smaller Than World. -func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, // currentZoomFp = 1.0x - 80, 80, // viewport px - 100*SCALE, 100*SCALE, // world fp - 0, 0, - ) - - // No anti-wrap needed, and we do not auto-fit by lowering zoom. - require.Equal(t, SCALE, got) -} - -// TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger verifies correct Camera Zoom Fp Raises Zoom To Prevent Wrap When Viewport Is Larger. -func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) { - t.Parallel() - - // World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large. - got := correctCameraZoomFp( - SCALE, - 120, 20, - 100*SCALE, 100*SCALE, - 0, 0, - ) - - require.Equal(t, 1200, got) // 1.2x -} - -// TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Wrap Conflict. -func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 4*SCALE, // user wants 4x - 20, 20, - 100*SCALE, 100*SCALE, - 0, 3*SCALE, // max 3x - ) - - require.Equal(t, 3*SCALE, got) -} - -// TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom verifies correct Camera Zoom Fp Anti Wrap Beats Max Zoom. -func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) { - t.Parallel() - - // requiredFit = 2x, but max is 1.5x => must return 2x. - got := correctCameraZoomFp( - SCALE, - 200, 20, - 100*SCALE, 100*SCALE, - 0, 1500, - ) - - require.Equal(t, 2*SCALE, got) -} - -// TestCorrectCameraZoomFp_AppliesMinZoom verifies correct Camera Zoom Fp Applies Min Zoom. -func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - 800, // 0.8x - 20, 20, - 100*SCALE, 100*SCALE, - SCALE, 0, // min 1.0x - ) - - require.Equal(t, SCALE, got) -} - -// TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds. -func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) { - t.Parallel() - - got := correctCameraZoomFp( - SCALE, - 0, 0, - 100*SCALE, 100*SCALE, - 1500, 0, - ) - - require.Equal(t, 1500, got) -} - -// TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld verifies clamp Camera No Wrap Viewport Clamps To Keep Viewport Inside World. -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) -} - -// TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter verifies clamp Camera No Wrap Viewport When Viewport Larger Than World Forces Center. -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) -} - -// TestWorldClampRenderParamsNoWrap_UsesViewportClamp verifies world Clamp Render Params No Wrap Uses Viewport Clamp. -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) -} - -// TestPivotZoom_CursorAtCenter_KeepsCamera verifies pivot Zoom Cursor At Center Keeps Camera. -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) -} - -// TestPivotZoom_RightEdge_ZoomInMovesCameraRight verifies pivot Zoom Right Edge Zoom In Moves Camera Right. -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) -} - -// TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft verifies pivot Zoom Left Edge Zoom In Moves Camera Left. -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/world.go b/client/world/world.go deleted file mode 100644 index 5bdabe3..0000000 --- a/client/world/world.go +++ /dev/null @@ -1,1014 +0,0 @@ -package world - -import ( - "errors" - "fmt" -) - -var ( - errBadCoordinate = errors.New("invalid coordinates") - errBadRadius = errors.New("invalid radius") - errNoSuchObject = errors.New("no such object") - errIDExhausted = errors.New("primitive id exhausted") -) - -// indexState stores the viewport-dependent parameters required to rebuild the -// spatial grid after object or style-affecting mutations. -type indexState struct { - initialized bool - viewportW int - viewportH int - zoomFp int -} - -// derivedStyleKey identifies one cached derived style by its base style and -// stable override fingerprint. -type derivedStyleKey struct { - base StyleID - fp uint64 -} - -// World stores torus world dimensions, all registered objects, -// and the grid-based spatial index built for the current viewport settings. -type World struct { - W, H int // Fixed-point world size. - grid [][][]MapItem - cellSize int - rows, cols int - objects map[PrimitiveID]MapItem - - styles *StyleTable - theme StyleTheme - themeDefaultLineStyleID StyleID - themeDefaultCircleStyleID StyleID - themeDefaultPointStyleID StyleID - - circleRadiusScaleFp int // fixed-point, 1.0 == SCALE - - // PrimitiveID allocator state. - nextID PrimitiveID - freeIDs []PrimitiveID - - // Index dirty flag for add/remove updates. - indexDirty bool - index indexState - - renderState rendererIncrementalState - derivedCache map[derivedStyleKey]StyleID - - // scratch buffers for hot render path (single goroutine assumption). - scratchDrawItems []drawItem - scratchWrapShifts []wrapShift - scratchLineSegs []lineSeg - scratchLineSegsTmp []lineSeg - - // candidate dedupe scratch (hot path for plan building). - candStamp []uint32 - candEpoch uint32 - scratchCandidates []MapItem -} - -// NewWorld constructs a new world with the given real dimensions. -// The dimensions are converted to fixed-point and must be positive. -func NewWorld(width, height int) *World { - if width <= 0 || height <= 0 { - panic("invalid width or height") - } - return &World{ - W: width * SCALE, - H: height * SCALE, - cellSize: 1, - objects: make(map[PrimitiveID]MapItem), - styles: NewStyleTable(), - theme: DefaultTheme{}, - - // At startup, "theme defaults" point to conservative built-ins. - themeDefaultLineStyleID: StyleIDDefaultLine, - themeDefaultCircleStyleID: StyleIDDefaultCircle, - themeDefaultPointStyleID: StyleIDDefaultPoint, - - circleRadiusScaleFp: SCALE, - - derivedCache: make(map[derivedStyleKey]StyleID, 128), - - nextID: 1, // 0 is reserved as "invalid" - } -} - -// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter. -// It returns an error if the ID space is exhausted. -func (w *World) allocID() (PrimitiveID, error) { - if n := len(w.freeIDs); n > 0 { - id := w.freeIDs[n-1] - w.freeIDs = w.freeIDs[:n-1] - return id, nil - } - if w.nextID == PrimitiveID(^uint32(0)) { - return 0, errIDExhausted - } - id := w.nextID - w.nextID++ - return id, nil -} - -// freeID returns an id back to the pool. It is safe to call only after the object is removed. -func (w *World) freeID(id PrimitiveID) { - if id == 0 { - return - } - w.freeIDs = append(w.freeIDs, id) -} - -// checkCoordinate reports whether the fixed-point coordinate (xf, yf) -// lies inside the world bounds: [0, W) x [0, H). -func (w *World) checkCoordinate(xf, yf int) bool { - if xf < 0 || xf >= w.W || yf < 0 || yf >= w.H { - return false - } - return true -} - -// AddStyleLine creates a new line style derived from the default line style. -func (w *World) AddStyleLine(override StyleOverride) StyleID { - return w.styles.AddDerived(StyleIDDefaultLine, override) -} - -// AddStyleCircle creates a new circle style derived from the default circle style. -func (w *World) AddStyleCircle(override StyleOverride) StyleID { - return w.styles.AddDerived(StyleIDDefaultCircle, override) -} - -// AddStylePoint creates a new point style derived from the default point style. -func (w *World) AddStylePoint(override StyleOverride) StyleID { - return w.styles.AddDerived(StyleIDDefaultPoint, override) -} - -// Theme returns the current theme. It is never nil. -func (w *World) Theme() StyleTheme { - if w.theme == nil { - return DefaultTheme{} - } - return w.theme -} - -// SetTheme updates the world's current theme. -func (w *World) SetTheme(theme StyleTheme) { - if theme == nil { - theme = DefaultTheme{} - } - w.theme = theme - - // Drop derived cache when theme changes to avoid unbounded growth. - for k := range w.derivedCache { - delete(w.derivedCache, k) - } - - // Materialize theme base styles as new IDs. - w.themeDefaultLineStyleID = w.styles.AddStyle(theme.LineStyle()) - w.themeDefaultCircleStyleID = w.styles.AddStyle(theme.CircleStyle()) - w.themeDefaultPointStyleID = w.styles.AddStyle(theme.PointStyle()) - - w.refreshThemeManagedStyles() - - // Full redraw to apply new background and base styles. - w.renderState.Reset() - w.ForceFullRedrawNext() -} - -func (w *World) themeBaseStyleID(base styleBase) StyleID { - switch base { - case styleBaseThemeLine: - return w.themeDefaultLineStyleID - case styleBaseThemeCircle: - return w.themeDefaultCircleStyleID - case styleBaseThemePoint: - return w.themeDefaultPointStyleID - default: - return StyleIDInvalid - } -} - -// refreshThemeManagedStyles recomputes resolved StyleID values for primitives -// that track the active theme rather than a fixed explicit style. -func (w *World) refreshThemeManagedStyles() { - th := w.Theme() - - for id, it := range w.objects { - switch v := it.(type) { - case Point: - if v.Base == styleBaseFixed { - continue - } - baseID := w.themeBaseStyleID(v.Base) - if baseID == StyleIDInvalid { - continue - } - - classOv := StyleOverride{} - if ov, ok := th.PointClassOverride(v.Class); ok { - classOv = ov - } - merged := mergeOverrides(classOv, v.Override) - v.StyleID = w.derivedStyleID(baseID, merged) - - w.objects[id] = v - - case Circle: - if v.Base == styleBaseFixed { - continue - } - baseID := w.themeBaseStyleID(v.Base) - if baseID == StyleIDInvalid { - continue - } - - classOv := StyleOverride{} - if ov, ok := th.CircleClassOverride(v.Class); ok { - classOv = ov - } - merged := mergeOverrides(classOv, v.Override) - v.StyleID = w.derivedStyleID(baseID, merged) - - w.objects[id] = v - - case Line: - if v.Base == styleBaseFixed { - continue - } - baseID := w.themeBaseStyleID(v.Base) - if baseID == StyleIDInvalid { - continue - } - - classOv := StyleOverride{} - if ov, ok := th.LineClassOverride(v.Class); ok { - classOv = ov - } - merged := mergeOverrides(classOv, v.Override) - v.StyleID = w.derivedStyleID(baseID, merged) - - w.objects[id] = v - - default: - panic("refreshThemeManagedStyles: unknown item type") - } - } - - w.ForceFullRedrawNext() -} - -// derivedStyleID resolves a derived style, reusing the per-world cache when an -// identical base style and override combination has already been materialized. -func (w *World) derivedStyleID(base StyleID, ov StyleOverride) StyleID { - if ov.IsZero() { - return base - } - k := derivedStyleKey{base: base, fp: ov.fingerprint()} - if id, ok := w.derivedCache[k]; ok { - return id - } - id := w.styles.AddDerived(base, ov) - w.derivedCache[k] = id - return id -} - -// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown. -// It marks the spatial index dirty and triggers an autonomous rebuild if possible. -func (w *World) Remove(id PrimitiveID) error { - if _, ok := w.objects[id]; !ok { - return errNoSuchObject - } - delete(w.objects, id) - w.freeID(id) - - w.indexDirty = true - w.rebuildIndexFromLastState() - return nil -} - -// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state -// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true. -func (w *World) Reindex() { - w.indexDirty = true - w.rebuildIndexFromLastState() -} - -// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp -// from renderer state. If that state is not initialized, it does nothing. -func (w *World) rebuildIndexFromLastState() { - if !w.indexDirty { - return - } - if !w.index.initialized { - return - } - if w.index.viewportW <= 0 || w.index.viewportH <= 0 || w.index.zoomFp <= 0 { - return - } - - w.rebuildIndexForViewportZoomFp(w.index.viewportW, w.index.viewportH, w.index.zoomFp) - w.indexDirty = false -} - -// AddPoint validates and stores a point primitive in the world. -// The input coordinates are given in real world units and are converted -// to fixed-point before validation. -func (w *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) { - xf := fixedPoint(x) - yf := fixedPoint(y) - if ok := w.checkCoordinate(xf, yf); !ok { - return 0, errBadCoordinate - } - - o := defaultPointOptions() - for _, opt := range opts { - if opt != nil { - opt(&o) - } - } - // styleID := g.resolvePointStyleID(o) - - id, err := w.allocID() - if err != nil { - return 0, err - } - - obj := Point{ - Id: id, - X: xf, - Y: yf, - Priority: o.Priority, - // StyleID: styleID, - HitSlopPx: o.HitSlopPx, - } - - obj.Class = o.Class - if o.hasStyleID { - obj.Base = styleBaseFixed - obj.StyleID = o.StyleID - obj.Override = StyleOverride{} - } else { - obj.Base = styleBaseThemePoint - baseID := w.themeDefaultPointStyleID - - // class override from current theme (may be absent) - classOv := StyleOverride{} - if th := w.Theme(); th != nil { - if ov, ok := th.PointClassOverride(obj.Class); ok { - classOv = ov - } - } - - merged := mergeOverrides(classOv, o.Override) - obj.Override = o.Override // store only user override; class override comes from theme - obj.StyleID = w.derivedStyleID(baseID, merged) - } - - w.objects[id] = obj - w.indexDirty = true - w.rebuildIndexFromLastState() - - return id, nil -} - -// AddCircle validates and stores a circle primitive in the world. -// The center and radius are given in real world units and are converted -// to fixed-point before validation. A zero radius is allowed. -func (w *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) { - xf := fixedPoint(x) - yf := fixedPoint(y) - - if ok := w.checkCoordinate(xf, yf); !ok { - return 0, errBadCoordinate - } - if r < 0 { - return 0, errBadRadius - } - - o := defaultCircleOptions() - for _, opt := range opts { - if opt != nil { - opt(&o) - } - } - - id, err := w.allocID() - if err != nil { - return 0, err - } - - obj := Circle{ - Id: id, - X: xf, - Y: yf, - Radius: fixedPoint(r), - Priority: o.Priority, - HitSlopPx: o.HitSlopPx, - } - - obj.Class = o.Class - if o.hasStyleID { - obj.Base = styleBaseFixed - obj.StyleID = o.StyleID - obj.Override = StyleOverride{} - } else { - obj.Base = styleBaseThemeCircle - baseID := w.themeDefaultCircleStyleID - - // class override from current theme (may be absent) - classOv := StyleOverride{} - if th := w.Theme(); th != nil { - if ov, ok := th.CircleClassOverride(obj.Class); ok { - classOv = ov - } - } - - merged := mergeOverrides(classOv, o.Override) - obj.Override = o.Override // store only user override; class override comes from theme - obj.StyleID = w.derivedStyleID(baseID, merged) - } - - w.objects[id] = obj - w.indexDirty = true - w.rebuildIndexFromLastState() - - return id, nil -} - -// AddLine validates and stores a line primitive in the world. -// The endpoints are given in real world units and are converted -// to fixed-point before validation. -func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) { - x1f := fixedPoint(x1) - y1f := fixedPoint(y1) - x2f := fixedPoint(x2) - y2f := fixedPoint(y2) - - if ok := w.checkCoordinate(x1f, y1f); !ok { - return 0, errBadCoordinate - } - if ok := w.checkCoordinate(x2f, y2f); !ok { - return 0, errBadCoordinate - } - - o := defaultLineOptions() - for _, opt := range opts { - if opt != nil { - opt(&o) - } - } - // styleID := g.resolveLineStyleID(o) - - id, err := w.allocID() - if err != nil { - return 0, err - } - - obj := Line{ - Id: id, - X1: x1f, - Y1: y1f, - X2: x2f, - Y2: y2f, - Priority: o.Priority, - // StyleID: styleID, - HitSlopPx: o.HitSlopPx, - } - - obj.Class = o.Class - if o.hasStyleID { - obj.Base = styleBaseFixed - obj.StyleID = o.StyleID - obj.Override = StyleOverride{} - } else { - obj.Base = styleBaseThemeLine - baseID := w.themeDefaultLineStyleID - - // class override from current theme (may be absent) - classOv := StyleOverride{} - if th := w.Theme(); th != nil { - if ov, ok := th.LineClassOverride(obj.Class); ok { - classOv = ov - } - } - - merged := mergeOverrides(classOv, o.Override) - obj.Override = o.Override // store only user override; class override comes from theme - obj.StyleID = w.derivedStyleID(baseID, merged) - } - - w.objects[id] = obj - w.indexDirty = true - w.rebuildIndexFromLastState() - - return id, nil -} - -// worldToCellX converts a fixed-point X coordinate to a grid column index. -func (w *World) worldToCellX(x int) int { - return worldToCell(x, w.W, w.cols, w.cellSize) -} - -// worldToCellY converts a fixed-point Y coordinate to a grid row index. -func (w *World) worldToCellY(y int) int { - return worldToCell(y, w.H, w.rows, w.cellSize) -} - -// resetGrid recreates the spatial grid with the given cell size -// and clears all previous indexing state. -func (w *World) resetGrid(cellSize int) { - if cellSize <= 0 { - panic("resetGrid: invalid cell size") - } - - w.cellSize = cellSize - w.cols = ceilDiv(w.W, w.cellSize) - w.rows = ceilDiv(w.H, w.cellSize) - - w.grid = make([][][]MapItem, w.rows) - for row := range w.grid { - w.grid[row] = make([][]MapItem, w.cols) - } -} - -// indexObject inserts a single object into all grid cells touched by its -// indexing representation. Points are inserted into one cell, while circles -// and lines are inserted by their torus-aware bbox coverage. -func (w *World) indexObject(o MapItem) { - switch mapItem := o.(type) { - case Point: - col := w.worldToCellX(mapItem.X) - row := w.worldToCellY(mapItem.Y) - w.grid[row][col] = append(w.grid[row][col], mapItem) - - case Line: - x1 := mapItem.X1 - y1 := mapItem.Y1 - x2 := mapItem.X2 - y2 := mapItem.Y2 - - x1, x2 = shortestWrappedDelta(x1, x2, w.W) - y1, y2 = shortestWrappedDelta(y1, y2, w.H) - - minX := min(x1, x2) - maxX := max(x1, x2) - minY := min(y1, y2) - maxY := max(y1, y2) - - if minX == maxX { - maxX++ - } - if minY == maxY { - maxY++ - } - - w.indexBBox(mapItem, minX, maxX, minY, maxY) - - case Circle: - rEff := circleRadiusEffFp(mapItem.Radius, w.circleRadiusScaleFp) - w.indexBBox(mapItem, - mapItem.X-rEff, mapItem.X+rEff, - mapItem.Y-rEff, mapItem.Y+rEff, - ) - - default: - panic(fmt.Sprintf("indexing: unknown element %T", mapItem)) - } -} - -// indexBBox indexes an object by a half-open fixed-point bbox that may cross -// torus boundaries. The bbox is split into wrapped in-world rectangles first, -// then all covered grid cells are populated. -func (w *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { - rects := splitByWrap(w.W, w.H, minX, maxX, minY, maxY) - for _, r := range rects { - colStart := w.worldToCellX(r.minX) - colEnd := w.worldToCellX(r.maxX - 1) - - rowStart := w.worldToCellY(r.minY) - rowEnd := w.worldToCellY(r.maxY - 1) - - for col := colStart; col <= colEnd; col++ { - for row := rowStart; row <= rowEnd; row++ { - w.grid[row][col] = append(w.grid[row][col], o) - } - } - } -} - -// IndexOnViewportChange is called when UI window sizes are changed. -// cameraZoom is float64, converted inside world to fixed-point. -func (w *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) { - zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code - - // Remember params for autonomous reindex after Add/Remove. - w.index.initialized = true - w.index.viewportW = viewportWidthPx - w.index.viewportH = viewportHeightPx - w.index.zoomFp = zoomFp - - w.rebuildIndexForViewportZoomFp(viewportWidthPx, viewportHeightPx, zoomFp) - w.indexDirty = false -} - -// rebuildIndexForViewportZoomFp rebuilds the spatial grid for a particular -// viewport size and fixed-point zoom. -// -// The chosen cell size is derived from the currently visible world span and -// then clamped into the package-wide cell-size bounds. -func (w *World) rebuildIndexForViewportZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) { - worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp) - - cellsAcrossMin := 8 - visibleMin := min(worldWidth, worldHeight) - - cellSize := visibleMin / cellsAcrossMin - cellSize = clamp(cellSize, cellSizeMin, cellSizeMax) - - w.resetGrid(cellSize) - - for _, o := range w.objects { - w.indexObject(o) - } -} - -// CircleRadiusScaleFp returns the current circle radius scale (fixed-point). -func (w *World) CircleRadiusScaleFp() int { - return w.circleRadiusScaleFp -} - -// SetCircleRadiusScaleFp sets the circle radius scale (fixed-point). -// scaleFp must be > 0. This affects indexing, rendering and hit-testing, -// so it forces a full redraw and triggers reindex when possible. -func (w *World) SetCircleRadiusScaleFp(scaleFp int) error { - if scaleFp <= 0 { - return errors.New("invalid circle radius scale") - } - if scaleFp == w.circleRadiusScaleFp { - return nil - } - w.circleRadiusScaleFp = scaleFp - - // Radius scale affects circle bbox => spatial index must be rebuilt. - w.indexDirty = true - w.rebuildIndexFromLastState() - - // Visual change => full redraw. - w.ForceFullRedrawNext() - return nil -} - -// circleRadiusEffFp converts a raw circle radius (world-fixed) into effective radius (world-fixed) -// using g.circleRadiusScaleFp. -func circleRadiusEffFp(rawRadiusFp, circleRadiusScaleFp int) int { - // Use int64 to avoid overflow. - v := (int64(rawRadiusFp) * int64(circleRadiusScaleFp)) / int64(SCALE) - if v < 0 { - return 0 - } - if v > int64(^uint(0)>>1) { - // Defensive; should never happen with sane inputs on 64-bit. - return int(^uint(0) >> 1) - } - return int(v) -} - -// PointClassID classifies Point primitives for theme-level style overrides. -// -// Themes may use the class to derive a final style from the point base style -// without changing the primitive geometry itself. -type PointClassID uint8 - -const ( - // PointClassDefault selects the theme's default point styling. - PointClassDefault PointClassID = iota - // PointClassTrackUnknown marks a point as an unknown track marker. - PointClassTrackUnknown - // PointClassTrackIncoming marks a point as an incoming track marker. - PointClassTrackIncoming - // PointClassTrackOutgoing marks a point as an outgoing track marker. - PointClassTrackOutgoing - // PointClassUnidentifiedPlanet marks an unidentified planet without visivle size. - PointClassUnidentifiedPlanet -) - -// LineClassID classifies Line primitives for theme-level style overrides. -type LineClassID uint8 - -const ( - // LineClassDefault selects the theme's default line styling. - LineClassDefault LineClassID = iota - // LineClassTrackIncoming marks a line as an incoming track. - LineClassTrackIncoming - // LineCLassTrackOutgoing marks a line as an outgoing track. - // The unusual spelling is preserved for backward compatibility. - LineCLassTrackOutgoing - // LineClassMeasurement marks a line as a measurement helper. - LineClassMeasurement -) - -// CircleClassID classifies Circle primitives for theme-level style overrides. -type CircleClassID uint8 - -const ( - // CircleClassDefault selects the theme's default circle styling. - CircleClassDefault CircleClassID = iota - // CircleClassLocalPlanet marks a circle as a player-owned planet. - CircleClassLocalPlanet - // CircleClassOthersPlanet marks a circle as an occupied planet. - CircleClassOthersPlanet - // CircleClassFreePlanet marks a circle as a free planet. - CircleClassFreePlanet -) - -// PrimitiveID is a compact stable identifier for primitives stored in the World. -// It is allocated by the World and may be reused after deletion (free-list). -type PrimitiveID uint32 - -// MapItem is the common interface implemented by all world primitives. -type MapItem interface { - ID() PrimitiveID -} - -// styleBase describes how a primitive resolves its base style across theme changes. -type styleBase uint8 - -const ( - styleBaseFixed styleBase = iota - styleBaseThemeLine - styleBaseThemeCircle - styleBaseThemePoint -) - -// Point is a point primitive in fixed-point world coordinates. -type Point struct { - Id PrimitiveID - X, Y int - - // Priority controls per-object draw ordering. Smaller draws earlier. - Priority int - // StyleID references a resolved style in the world's style table. - StyleID StyleID - // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. - Base styleBase - // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). - Override StyleOverride - Class PointClassID - - // HitSlopPx expands hit-test radius in screen pixels (per-object override). - // 0 means "use primitive default". - HitSlopPx int -} - -// Line is a line segment primitive in fixed-point world coordinates. -type Line struct { - Id PrimitiveID - X1, Y1 int - X2, Y2 int - - // Priority controls per-object draw ordering. Smaller draws earlier. - Priority int - // StyleID references a resolved style in the world's style table. - StyleID StyleID - // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. - Base styleBase - // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). - Override StyleOverride - Class LineClassID - - // HitSlopPx expands hit-test radius in screen pixels (per-object override). - // 0 means "use primitive default". - HitSlopPx int -} - -// Circle is a circle primitive in fixed-point world coordinates. -type Circle struct { - Id PrimitiveID - X, Y int - Radius int - - // Priority controls per-object draw ordering. Smaller draws earlier. - Priority int - // StyleID references a resolved style in the world's style table. - StyleID StyleID - // Theme style binding. If Base==styleBaseFixed => StyleID stays as-is across theme changes. - Base styleBase - // Override is applied relative to current theme base style (only when Base is theme* and Override is non-zero). - Override StyleOverride - Class CircleClassID - - // HitSlopPx expands hit-test radius in screen pixels (per-object override). - // 0 means "use primitive default". - HitSlopPx int -} - -// ID returns the point identifier. -func (p Point) ID() PrimitiveID { return p.Id } - -// ID returns the line identifier. -func (l Line) ID() PrimitiveID { return l.Id } - -// ID returns the circle identifier. -func (c Circle) ID() PrimitiveID { return c.Id } - -// MinX returns the minimum X endpoint coordinate of the line. -func (l Line) MinX() int { return min(l.X1, l.X2) } - -// MaxX returns the maximum X endpoint coordinate of the line. -func (l Line) MaxX() int { return max(l.X1, l.X2) } - -// MinY returns the minimum Y endpoint coordinate of the line. -func (l Line) MinY() int { return min(l.Y1, l.Y2) } - -// MaxY returns the maximum Y endpoint coordinate of the line. -func (l Line) MaxY() int { return max(l.Y1, l.Y2) } - -// MinX returns the minimum X coordinate of the circle bbox. -func (c Circle) MinX() int { return c.X - c.Radius } - -// MaxX returns the maximum X coordinate of the circle bbox. -func (c Circle) MaxX() int { return c.X + c.Radius } - -// MinY returns the minimum Y coordinate of the circle bbox. -func (c Circle) MinY() int { return c.Y - c.Radius } - -// MaxY returns the maximum Y coordinate of the circle bbox. -func (c Circle) MaxY() int { return c.Y + c.Radius } - -// PointOpt applies optional point-construction parameters to PointOptions. -type PointOpt func(*PointOptions) - -// PointOptions stores optional arguments accepted by World.AddPoint. -// -// Defaults are resolved before applying user-provided PointOpt values. -type PointOptions struct { - Priority int - StyleID StyleID - Override StyleOverride - Class PointClassID - - HitSlopPx int - - hasStyleID bool -} - -// defaultPointOptions returns the default option set used by World.AddPoint. -func defaultPointOptions() PointOptions { - return PointOptions{ - Priority: DefaultPriorityPoint, - StyleID: StyleIDDefaultPoint, - Class: PointClassDefault, - } -} - -// PointWithPriority sets point draw priority. -// -// Lower priorities render earlier within the same tile. -func PointWithPriority(p int) PointOpt { - return func(o *PointOptions) { - o.Priority = p - } -} - -// PointWithStyleID forces the point to use a pre-registered style. -func PointWithStyleID(id StyleID) PointOpt { - return func(o *PointOptions) { - o.StyleID = id - o.hasStyleID = true - // Explicit style ID wins over overrides. - o.Override = StyleOverride{} - } -} - -// PointWithClass selects the theme class used for point style resolution. -func PointWithClass(c PointClassID) PointOpt { - return func(o *PointOptions) { o.Class = c } -} - -// PointWithStyleOverride applies a user override on top of the resolved point base style. -// -// If PointWithStyleID is also supplied, the explicit style ID wins. -func PointWithStyleOverride(ov StyleOverride) PointOpt { - return func(o *PointOptions) { - o.Override = ov - } -} - -// PointWithHitSlopPx overrides the default point hit slop in screen pixels. -func PointWithHitSlopPx(px int) PointOpt { - return func(o *PointOptions) { o.HitSlopPx = px } -} - -// CircleOpt applies optional circle-construction parameters to CircleOptions. -type CircleOpt func(*CircleOptions) - -// CircleOptions stores optional arguments accepted by World.AddCircle. -type CircleOptions struct { - Priority int - StyleID StyleID - Override StyleOverride - Class CircleClassID - - HitSlopPx int - - hasStyleID bool -} - -// defaultCircleOptions returns the default option set used by World.AddCircle. -func defaultCircleOptions() CircleOptions { - return CircleOptions{ - Priority: DefaultPriorityCircle, - StyleID: StyleIDDefaultCircle, - Class: CircleClassDefault, - } -} - -// CircleWithPriority sets circle draw priority. -func CircleWithPriority(p int) CircleOpt { - return func(o *CircleOptions) { - o.Priority = p - } -} - -// CircleWithStyleID forces the circle to use a pre-registered style. -func CircleWithStyleID(id StyleID) CircleOpt { - return func(o *CircleOptions) { - o.StyleID = id - o.hasStyleID = true - o.Override = StyleOverride{} - } -} - -// CircleWithClass selects the theme class used for circle style resolution. -func CircleWithClass(c CircleClassID) CircleOpt { - return func(o *CircleOptions) { o.Class = c } -} - -// CircleWithStyleOverride applies a user override on top of the resolved circle base style. -func CircleWithStyleOverride(ov StyleOverride) CircleOpt { - return func(o *CircleOptions) { - o.Override = ov - } -} - -// CircleWithHitSlopPx overrides the default circle hit slop in screen pixels. -func CircleWithHitSlopPx(px int) CircleOpt { - return func(o *CircleOptions) { o.HitSlopPx = px } -} - -// LineOpt applies optional line-construction parameters to LineOptions. -type LineOpt func(*LineOptions) - -// LineOptions stores optional arguments accepted by World.AddLine. -type LineOptions struct { - Priority int - StyleID StyleID - Override StyleOverride - Class LineClassID - - HitSlopPx int - - hasStyleID bool -} - -// defaultLineOptions returns the default option set used by World.AddLine. -func defaultLineOptions() LineOptions { - return LineOptions{ - Priority: DefaultPriorityLine, - StyleID: StyleIDDefaultLine, - Class: LineClassDefault, - } -} - -// LineWithPriority sets line draw priority. -func LineWithPriority(p int) LineOpt { - return func(o *LineOptions) { - o.Priority = p - } -} - -// LineWithStyleID forces the line to use a pre-registered style. -func LineWithStyleID(id StyleID) LineOpt { - return func(o *LineOptions) { - o.StyleID = id - o.hasStyleID = true - o.Override = StyleOverride{} - } -} - -// LineWithClass selects the theme class used for line style resolution. -func LineWithClass(c LineClassID) LineOpt { - return func(o *LineOptions) { o.Class = c } -} - -// LineWithStyleOverride applies a user override on top of the resolved line base style. -func LineWithStyleOverride(ov StyleOverride) LineOpt { - return func(o *LineOptions) { - o.Override = ov - } -} - -// LineWithHitSlopPx overrides the default line hit slop in screen pixels. -func LineWithHitSlopPx(px int) LineOpt { - return func(o *LineOptions) { o.HitSlopPx = px } -} diff --git a/client/world/world_test.go b/client/world/world_test.go deleted file mode 100644 index e281a9f..0000000 --- a/client/world/world_test.go +++ /dev/null @@ -1,1893 +0,0 @@ -package world - -import ( - "errors" - "fmt" - "github.com/stretchr/testify/require" - "image/color" - "testing" -) - -func newIndexedTestWorld() *World { - w := NewWorld(10, 10) - w.SetCircleRadiusScaleFp(SCALE) - w.resetGrid(2 * SCALE) // 5x5 grid. - return w -} - -func cellHasOnlyID(t *testing.T, w *World, row, col int, want PrimitiveID) { - t.Helper() - - cell := w.grid[row][col] - if len(cell) != 1 { - t.Fatalf("cell[%d][%d] len = %d, want 1", row, col, len(cell)) - } - if got := cell[0].ID(); got != want { - t.Fatalf("cell[%d][%d] item id = %v, want %v", row, col, got, want) - } -} - -func cellIsEmpty(t *testing.T, w *World, row, col int) { - t.Helper() - - if got := len(w.grid[row][col]); got != 0 { - t.Fatalf("cell[%d][%d] len = %d, want 0", row, col, got) - } -} - -func occupiedCellsByID(w *World, id PrimitiveID) map[[2]int]struct{} { - result := make(map[[2]int]struct{}) - - for row := range w.grid { - for col := range w.grid[row] { - for _, item := range w.grid[row][col] { - if item.ID() == id { - result[[2]int{row, col}] = struct{}{} - } - } - } - } - - return result -} - -func assertOccupiedCells(t *testing.T, w *World, id PrimitiveID, want ...[2]int) { - t.Helper() - - got := occupiedCellsByID(w, id) - if len(got) != len(want) { - t.Fatalf("occupied cell count = %d, want %d; got=%v want=%v", len(got), len(want), got, want) - } - - for _, cell := range want { - if _, ok := got[cell]; !ok { - t.Fatalf("missing occupied cell row=%d col=%d; got=%v", cell[0], cell[1], got) - } - } -} - -// TestNewWorld verifies new World. -func TestNewWorld(t *testing.T) { - t.Parallel() - - w := NewWorld(12, 7) - - if w.W != 12*SCALE { - t.Fatalf("W = %d, want %d", w.W, 12*SCALE) - } - if w.H != 7*SCALE { - t.Fatalf("H = %d, want %d", w.H, 7*SCALE) - } - if w.cellSize != 1 { - t.Fatalf("cellSize = %d, want 1", w.cellSize) - } - if w.objects == nil { - t.Fatal("objects map is nil") - } -} - -// TestNewWorldPanicsOnInvalidSize verifies new World Panics On Invalid Size. -func TestNewWorldPanicsOnInvalidSize(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - width int - height int - }{ - {name: "zero width", width: 0, height: 1}, - {name: "zero height", width: 1, height: 0}, - {name: "negative width", width: -1, height: 1}, - {name: "negative height", width: 1, height: -1}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - defer func() { - if recover() == nil { - t.Fatalf("NewWorld(%d, %d) did not panic", tt.width, tt.height) - } - }() - - _ = NewWorld(tt.width, tt.height) - }) - } -} - -// TestCheckCoordinate verifies check Coordinate. -func TestCheckCoordinate(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - tests := []struct { - name string - xf int - yf int - want bool - }{ - {name: "origin", xf: 0, yf: 0, want: true}, - {name: "inside", xf: 5000, yf: 5000, want: true}, - {name: "last valid", xf: 9999, yf: 9999, want: true}, - {name: "x below", xf: -1, yf: 0, want: false}, - {name: "y below", xf: 0, yf: -1, want: false}, - {name: "x equal width", xf: 10000, yf: 0, want: false}, - {name: "y equal height", xf: 0, yf: 10000, want: false}, - } - - for _, tt := range tests { - if got := w.checkCoordinate(tt.xf, tt.yf); got != tt.want { - t.Fatalf("checkCoordinate(%d, %d) = %v, want %v", tt.xf, tt.yf, got, tt.want) - } - } -} - -// TestAddPoint verifies add Point. -func TestAddPoint(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id, err := w.AddPoint(1.25, 2.75) - if err != nil { - t.Fatalf("AddPoint returned error: %v", err) - } - - item, ok := w.objects[id] - if !ok { - t.Fatalf("point with id %v was not stored", id) - } - - p, ok := item.(Point) - if !ok { - t.Fatalf("stored item type = %T, want Point", item) - } - if p.X != 1250 || p.Y != 2750 { - t.Fatalf("stored point = (%d, %d), want (1250, 2750)", p.X, p.Y) - } -} - -// TestAddPointRejectsOutOfBounds verifies add Point Rejects Out Of Bounds. -func TestAddPointRejectsOutOfBounds(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - tests := []struct { - name string - x float64 - y float64 - }{ - {name: "negative x", x: -0.001, y: 1}, - {name: "negative y", x: 1, y: -0.001}, - {name: "x rounds to width", x: 9.9995, y: 1}, - {name: "y rounds to height", x: 1, y: 9.9995}, - {name: "x clearly outside", x: 10, y: 1}, - {name: "y clearly outside", x: 1, y: 10}, - } - - for _, tt := range tests { - _, err := w.AddPoint(tt.x, tt.y) - if !errors.Is(err, errBadCoordinate) { - t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) - } - } -} - -// TestAddPointAllowsLastRoundedInsideValue verifies add Point Allows Last Rounded Inside Value. -func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id, err := w.AddPoint(9.9994, 9.9994) - if err != nil { - t.Fatalf("AddPoint returned error: %v", err) - } - - p := w.objects[id].(Point) - if p.X != 9999 || p.Y != 9999 { - t.Fatalf("stored point = (%d, %d), want (9999, 9999)", p.X, p.Y) - } -} - -// TestAddCircle verifies add Circle. -func TestAddCircle(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id, err := w.AddCircle(2.5, 3.5, 1.25) - if err != nil { - t.Fatalf("AddCircle returned error: %v", err) - } - - item, ok := w.objects[id] - if !ok { - t.Fatalf("circle with id %v was not stored", id) - } - - c, ok := item.(Circle) - if !ok { - t.Fatalf("stored item type = %T, want Circle", item) - } - if c.X != 2500 || c.Y != 3500 || c.Radius != 1250 { - t.Fatalf("stored circle = (%d, %d, %d), want (2500, 3500, 1250)", c.X, c.Y, c.Radius) - } -} - -// TestAddCircleAllowsZeroRadius verifies add Circle Allows Zero Radius. -func TestAddCircleAllowsZeroRadius(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id, err := w.AddCircle(2, 3, 0) - if err != nil { - t.Fatalf("AddCircle returned error: %v", err) - } - - c := w.objects[id].(Circle) - if c.Radius != 0 { - t.Fatalf("radius = %d, want 0", c.Radius) - } -} - -// TestAddCircleRejectsInvalidInput verifies add Circle Rejects Invalid Input. -func TestAddCircleRejectsInvalidInput(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - if _, err := w.AddCircle(1, 1, -0.001); !errors.Is(err, errBadRadius) { - t.Fatalf("negative radius error = %v, want %v", err, errBadRadius) - } - - tests := []struct { - name string - x float64 - y float64 - }{ - {name: "negative x", x: -0.001, y: 1}, - {name: "negative y", x: 1, y: -0.001}, - {name: "x rounds to width", x: 9.9995, y: 1}, - {name: "y rounds to height", x: 1, y: 9.9995}, - } - - for _, tt := range tests { - _, err := w.AddCircle(tt.x, tt.y, 1) - if !errors.Is(err, errBadCoordinate) { - t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) - } - } -} - -// TestAddLine verifies add Line. -func TestAddLine(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id, err := w.AddLine(1.1, 2.2, 3.3, 4.4) - if err != nil { - t.Fatalf("AddLine returned error: %v", err) - } - - item, ok := w.objects[id] - if !ok { - t.Fatalf("line with id %v was not stored", id) - } - - l, ok := item.(Line) - if !ok { - t.Fatalf("stored item type = %T, want Line", item) - } - if l.X1 != 1100 || l.Y1 != 2200 || l.X2 != 3300 || l.Y2 != 4400 { - t.Fatalf("stored line = (%d, %d) -> (%d, %d), want (1100, 2200) -> (3300, 4400)", - l.X1, l.Y1, l.X2, l.Y2) - } -} - -// TestAddLineRejectsInvalidInput verifies add Line Rejects Invalid Input. -func TestAddLineRejectsInvalidInput(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - tests := []struct { - name string - x1 float64 - y1 float64 - x2 float64 - y2 float64 - }{ - {name: "first point x below", x1: -0.001, y1: 1, x2: 2, y2: 2}, - {name: "first point y below", x1: 1, y1: -0.001, x2: 2, y2: 2}, - {name: "second point x below", x1: 1, y1: 1, x2: -0.001, y2: 2}, - {name: "second point y below", x1: 1, y1: 1, x2: 2, y2: -0.001}, - {name: "first point x rounds to width", x1: 9.9995, y1: 1, x2: 2, y2: 2}, - {name: "second point y rounds to height", x1: 1, y1: 1, x2: 2, y2: 9.9995}, - } - - for _, tt := range tests { - _, err := w.AddLine(tt.x1, tt.y1, tt.x2, tt.y2) - if !errors.Is(err, errBadCoordinate) { - t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) - } - } -} - -// TestResetGrid verifies reset Grid. -func TestResetGrid(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 6) - w.resetGrid(2 * SCALE) - - if w.cellSize != 2*SCALE { - t.Fatalf("cellSize = %d, want %d", w.cellSize, 2*SCALE) - } - if w.cols != 5 { - t.Fatalf("cols = %d, want 5", w.cols) - } - if w.rows != 3 { - t.Fatalf("rows = %d, want 3", w.rows) - } - if len(w.grid) != 3 { - t.Fatalf("len(grid) = %d, want 3", len(w.grid)) - } - for row := range w.grid { - if len(w.grid[row]) != 5 { - t.Fatalf("len(grid[%d]) = %d, want 5", row, len(w.grid[row])) - } - } -} - -// TestWorldToCellXY verifies world To Cell XY. -func TestWorldToCellXY(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - - if got := w.worldToCellX(2500); got != 1 { - t.Fatalf("worldToCellX(2500) = %d, want 1", got) - } - if got := w.worldToCellY(4500); got != 2 { - t.Fatalf("worldToCellY(4500) = %d, want 2", got) - } - if got := w.worldToCellX(-1); got != 4 { - t.Fatalf("worldToCellX(-1) = %d, want 4", got) - } - if got := w.worldToCellY(10000); got != 0 { - t.Fatalf("worldToCellY(10000) = %d, want 0", got) - } -} - -// TestIndexObjectPoint verifies index Object Point. -func TestIndexObjectPoint(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - p := Point{Id: id, X: 2500, Y: 4500} - w.indexObject(p) - - cellHasOnlyID(t, w, 2, 1, id) - cellIsEmpty(t, w, 0, 0) - cellIsEmpty(t, w, 4, 4) -} - -// TestIndexObjectCircleWithoutWrap verifies index Object Circle Without Wrap. -func TestIndexObjectCircleWithoutWrap(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900} - w.indexObject(c) - - assertOccupiedCells(t, w, id, - [2]int{0, 1}, - [2]int{1, 1}, - ) -} - -// TestIndexObjectCircleWrapsAcrossCorner verifies index Object Circle Wraps Across Corner. -func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - c := Circle{Id: id, X: 500, Y: 500, Radius: 900} - w.indexObject(c) - - assertOccupiedCells(t, w, id, - [2]int{0, 0}, - [2]int{0, 4}, - [2]int{4, 0}, - [2]int{4, 4}, - ) -} - -// TestIndexObjectCircleCoversWholeWorld verifies index Object Circle Covers Whole World. -func TestIndexObjectCircleCoversWholeWorld(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000} - w.indexObject(c) - - want := make([][2]int, 0, 25) - for row := 0; row < 5; row++ { - for col := 0; col < 5; col++ { - want = append(want, [2]int{row, col}) - } - } - - assertOccupiedCells(t, w, id, want...) -} - -// TestIndexObjectVerticalLineExpandsDegenerateX verifies index Object Vertical Line Expands Degenerate X. -func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000} - w.indexObject(l) - - assertOccupiedCells(t, w, id, - [2]int{0, 1}, - [2]int{1, 1}, - [2]int{2, 1}, - ) -} - -// TestIndexObjectHorizontalLineExpandsDegenerateY verifies index Object Horizontal Line Expands Degenerate Y. -func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000} - w.indexObject(l) - - assertOccupiedCells(t, w, id, - [2]int{1, 0}, - [2]int{1, 1}, - [2]int{1, 2}, - ) -} - -// TestIndexObjectLineWrapsAcrossX verifies index Object Line Wraps Across X. -func TestIndexObjectLineWrapsAcrossX(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000} - w.indexObject(l) - - assertOccupiedCells(t, w, id, - [2]int{1, 4}, - [2]int{1, 0}, - ) -} - -// TestIndexObjectLineWrapsAcrossY verifies index Object Line Wraps Across Y. -func TestIndexObjectLineWrapsAcrossY(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000} - w.indexObject(l) - - assertOccupiedCells(t, w, id, - [2]int{4, 1}, - [2]int{0, 1}, - ) -} - -// TestIndexObjectLineTieCaseUsesDeterministicWrap verifies index Object Line Tie Case Uses Deterministic Wrap. -func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - id := PrimitiveID(1) - - l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000} - w.indexObject(l) - - assertOccupiedCells(t, w, id, - [2]int{1, 3}, - [2]int{1, 4}, - [2]int{1, 0}, - ) -} - -type unknown struct { - id PrimitiveID -} - -func (u unknown) ID() PrimitiveID { - return u.id -} - -// TestIndexBBoxPanicsOnUnknownItemType verifies index B Box Panics On Unknown Item Type. -func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) { - t.Parallel() - - w := newIndexedTestWorld() - - defer func() { - if recover() == nil { - t.Fatal("indexObject did not panic for unknown item type") - } - }() - - w.indexObject(unknown{id: PrimitiveID(1)}) -} - -// TestAddPoint_DefaultsPriorityAndStyle verifies add Point Defaults Priority And Style. -func TestAddPoint_DefaultsPriorityAndStyle(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - id, err := w.AddPoint(1, 1) - require.NoError(t, err) - - obj := w.objects[id].(Point) - require.Equal(t, DefaultPriorityPoint, obj.Priority) - require.Equal(t, StyleIDDefaultPoint, obj.StyleID) -} - -// TestAddCircle_DefaultsPriorityAndStyle verifies add Circle Defaults Priority And Style. -func TestAddCircle_DefaultsPriorityAndStyle(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - id, err := w.AddCircle(1, 1, 1) - require.NoError(t, err) - - obj := w.objects[id].(Circle) - require.Equal(t, DefaultPriorityCircle, obj.Priority) - require.Equal(t, StyleIDDefaultCircle, obj.StyleID) -} - -// TestAddLine_DefaultsPriorityAndStyle verifies add Line Defaults Priority And Style. -func TestAddLine_DefaultsPriorityAndStyle(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - id, err := w.AddLine(1, 1, 2, 2) - require.NoError(t, err) - - obj := w.objects[id].(Line) - require.Equal(t, DefaultPriorityLine, obj.Priority) - require.Equal(t, StyleIDDefaultLine, obj.StyleID) -} - -// TestAddStyleLine_ThenUseStyleID verifies add Style Line Then Use Style ID. -func TestAddStyleLine_ThenUseStyleID(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - width := 5.0 - ov := StyleOverride{StrokeWidthPx: &width} - styleID := w.AddStyleLine(ov) - - id, err := w.AddLine(1, 1, 2, 2, LineWithStyleID(styleID), LineWithPriority(777)) - require.NoError(t, err) - - obj := w.objects[id].(Line) - require.Equal(t, 777, obj.Priority) - require.Equal(t, styleID, obj.StyleID) - - s, ok := w.styles.Get(styleID) - require.True(t, ok) - require.Equal(t, 5.0, s.StrokeWidthPx) -} - -// TestAddPoint_WithOverride_CreatesDerivedStyle verifies add Point With Override Creates Derived Style. -func TestAddPoint_WithOverride_CreatesDerivedStyle(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - newRadius := 9.0 - ov := StyleOverride{PointRadiusPx: &newRadius} - - id, err := w.AddPoint(1, 1, PointWithStyleOverride(ov)) - require.NoError(t, err) - - obj := w.objects[id].(Point) - require.NotEqual(t, StyleIDDefaultPoint, obj.StyleID) - - s, ok := w.styles.Get(obj.StyleID) - require.True(t, ok) - require.Equal(t, 9.0, s.PointRadiusPx) -} - -// TestExplicitStyleID_WinsOverOverride verifies explicit Style ID Wins Over Override. -func TestExplicitStyleID_WinsOverOverride(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - red := color.RGBA{R: 255, A: 255} - styleID := w.AddStyleCircle(StyleOverride{FillColor: red}) - - // Try to override radius in options too; StyleID must win, override must be ignored. - width := 123.0 - id, err := w.AddCircle(2, 2, 1, - CircleWithStyleID(styleID), - CircleWithStyleOverride(StyleOverride{StrokeWidthPx: &width}), - ) - require.NoError(t, err) - - obj := w.objects[id].(Circle) - require.Equal(t, styleID, obj.StyleID) - - s, ok := w.styles.Get(styleID) - require.True(t, ok) - require.Equal(t, red, s.FillColor) - // width override must not affect styleID. - require.NotEqual(t, 123.0, s.StrokeWidthPx) -} - -// TestWorldPrimitiveID_ReusesFreedIDs verifies world Primitive ID Reuses Freed I Ds. -func TestWorldPrimitiveID_ReusesFreedIDs(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - - id1, err := w.AddPoint(1, 1) - require.NoError(t, err) - - id2, err := w.AddPoint(2, 2) - require.NoError(t, err) - - require.NotEqual(t, id1, id2) - - require.NoError(t, w.Remove(id1)) - - id3, err := w.AddPoint(3, 3) - require.NoError(t, err) - - // LIFO free-list: id1 should be reused. - require.Equal(t, id1, id3) -} - -// TestWorldRemove_UnknownID verifies world Remove Unknown ID. -func TestWorldRemove_UnknownID(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - err := w.Remove(12345) - require.ErrorIs(t, err, errNoSuchObject) -} - -type gridCell struct { - Row int - Col int -} - -func newTestWorld(wReal, hReal int) *World { - w := NewWorld(wReal, hReal) - w.SetCircleRadiusScaleFp(SCALE) - return w -} - -func countObjectInGrid(g *World, id PrimitiveID) int { - count := 0 - for row := range g.grid { - for col := range g.grid[row] { - for _, item := range g.grid[row][col] { - if item.ID() == id { - count++ - } - } - } - } - return count -} - -func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool { - for _, item := range g.grid[row][col] { - if item.ID() == id { - return true - } - } - return false -} - -// TestViewportPxToWorldFixed verifies viewport Px To World Fixed. -func TestViewportPxToWorldFixed(t *testing.T) { - tests := []struct { - name string - viewportWidthPx int - viewportHeightPx int - cameraZoom int - wantWidth int - wantHeight int - }{ - { - name: "zoom 1.0", - viewportWidthPx: 500, - viewportHeightPx: 400, - cameraZoom: SCALE, - wantWidth: 500 * SCALE, - wantHeight: 400 * SCALE, - }, - { - name: "zoom 2.0", - viewportWidthPx: 500, - viewportHeightPx: 400, - cameraZoom: 2 * SCALE, - wantWidth: 250 * SCALE, - wantHeight: 200 * SCALE, - }, - { - name: "zoom below 1.0", - viewportWidthPx: 550, - viewportHeightPx: 550, - cameraZoom: 917, - wantWidth: 599781, - wantHeight: 599781, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotW, gotH := viewportPxToWorldFixed(tt.viewportWidthPx, tt.viewportHeightPx, tt.cameraZoom) - require.Equal(t, tt.wantWidth, gotW) - require.Equal(t, tt.wantHeight, gotH) - }) - } -} - -// TestSplitByWrap_ZeroOrNegativeSizeReturnsNil verifies split By Wrap Zero Or Negative Size Returns Nil. -func TestSplitByWrap_ZeroOrNegativeSizeReturnsNil(t *testing.T) { - tests := []struct { - name string - minX, maxX int - minY, maxY int - }{ - { - name: "zero width", - minX: 100, maxX: 100, - minY: 50, maxY: 100, - }, - { - name: "zero height", - minX: 100, maxX: 200, - minY: 50, maxY: 50, - }, - { - name: "negative width", - minX: 200, maxX: 100, - minY: 50, maxY: 100, - }, - { - name: "negative height", - minX: 100, maxX: 200, - minY: 100, maxY: 50, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rects := splitByWrap(600, 400, tt.minX, tt.maxX, tt.minY, tt.maxY) - require.Nil(t, rects) - }) - } -} - -// TestSplitByWrap_XWrapUsesWorldWidth verifies split By Wrap X Wrap Uses World Width. -func TestSplitByWrap_XWrapUsesWorldWidth(t *testing.T) { - rects := splitByWrap( - 600, 400, - 500, 650, - 50, 100, - ) - - require.Len(t, rects, 2) - require.Equal(t, Rect{minX: 500, maxX: 600, minY: 50, maxY: 100}, rects[0]) - require.Equal(t, Rect{minX: 0, maxX: 50, minY: 50, maxY: 100}, rects[1]) -} - -// TestSplitByWrap_YWrapUsesWorldHeight verifies split By Wrap Y Wrap Uses World Height. -func TestSplitByWrap_YWrapUsesWorldHeight(t *testing.T) { - rects := splitByWrap( - 600, 400, - 50, 100, - 350, 450, - ) - - require.Len(t, rects, 2) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 350, maxY: 400}, rects[0]) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 50}, rects[1]) -} - -// TestSplitByWrap_XAndYWrap verifies split By Wrap X And Y Wrap. -func TestSplitByWrap_XAndYWrap(t *testing.T) { - rects := splitByWrap( - 600, 400, - 550, 650, - 350, 450, - ) - - require.Len(t, rects, 4) - require.ElementsMatch(t, []Rect{ - {minX: 550, maxX: 600, minY: 350, maxY: 400}, - {minX: 550, maxX: 600, minY: 0, maxY: 50}, - {minX: 0, maxX: 50, minY: 350, maxY: 400}, - {minX: 0, maxX: 50, minY: 0, maxY: 50}, - }, rects) -} - -// TestSplitByWrap_NoWrapInsideWorld verifies split By Wrap No Wrap Inside World. -func TestSplitByWrap_NoWrapInsideWorld(t *testing.T) { - rects := splitByWrap( - 600, 400, - 100, 200, - 50, 100, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 100, maxX: 200, minY: 50, maxY: 100}, rects[0]) -} - -// TestSplitByWrap_FullWorldCoverageOnEqualWidth verifies split By Wrap Full World Coverage On Equal Width. -func TestSplitByWrap_FullWorldCoverageOnEqualWidth(t *testing.T) { - rects := splitByWrap( - 600, 400, - 0, 600, - 50, 100, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 0, maxX: 600, minY: 50, maxY: 100}, rects[0]) -} - -// TestSplitByWrap_FullWorldCoverageOnEqualHeight verifies split By Wrap Full World Coverage On Equal Height. -func TestSplitByWrap_FullWorldCoverageOnEqualHeight(t *testing.T) { - rects := splitByWrap( - 600, 400, - 50, 100, - 0, 400, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 400}, rects[0]) -} - -// TestSplitByWrap_FullWorldCoverageOnBothAxes verifies split By Wrap Full World Coverage On Both Axes. -func TestSplitByWrap_FullWorldCoverageOnBothAxes(t *testing.T) { - rects := splitByWrap( - 600, 400, - 0, 600, - 0, 400, - ) - - require.Len(t, rects, 1) - require.Equal(t, Rect{minX: 0, maxX: 600, minY: 0, maxY: 400}, rects[0]) -} - -// TestWorldToCell verifies world To Cell. -func TestWorldToCell(t *testing.T) { - tests := []struct { - name string - value int - worldSize int - cells int - cellSize int - want int - }{ - { - name: "simple inside world", - value: 150, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 1, - }, - { - name: "negative wraps to last cell", - value: -1, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 5, - }, - { - name: "exact world size wraps to zero", - value: 600, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 0, - }, - { - name: "large positive wraps correctly", - value: 650, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 0, - }, - { - name: "last in-range value lands in last cell", - value: 599, - worldSize: 600, - cells: 6, - cellSize: 100, - want: 5, - }, - {name: "first cell", value: 0, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, - {name: "middle cell", value: 2500, worldSize: 10000, cells: 5, cellSize: 2000, want: 1}, - {name: "last exact world point wraps to zero", value: 10000, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, - {name: "negative wraps to last", value: -1, worldSize: 10000, cells: 5, cellSize: 2000, want: 4}, - {name: "partial last cell is clamped", value: 9999, worldSize: 10000, cells: 4, cellSize: 3000, want: 3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := worldToCell(tt.value, tt.worldSize, tt.cells, tt.cellSize) - require.Equal(t, tt.want, got) - }) - } -} - -// TestResetGrid_UsesWidthForColsAndHeightForRows verifies reset Grid Uses Width For Cols And Height For Rows. -func TestResetGrid_UsesWidthForColsAndHeightForRows(t *testing.T) { - g := newTestWorld(600, 400) - - g.resetGrid(100 * SCALE) - - require.Equal(t, 6, g.cols) - require.Equal(t, 4, g.rows) - require.Len(t, g.grid, 4) - require.Len(t, g.grid[0], 6) -} - -// TestIndexPoint verifies index Point. -func TestIndexPoint(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: 150 * SCALE, - Y: 250 * SCALE, - } - - g.indexObject(p) - - require.True(t, hasObjectInCell(g, 2, 1, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -// TestIndexPoint_WrapsNegativeCoordinates verifies index Point Wraps Negative Coordinates. -func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: -1, - Y: -1, - } - - g.indexObject(p) - - require.True(t, hasObjectInCell(g, 5, 5, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -// TestIndexCircle_WrapsAcrossLeftAndTopEdges verifies index Circle Wraps Across Left And Top Edges. -func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 50 * SCALE, - Y: 50 * SCALE, - Radius: 75 * SCALE, - } - - g.indexObject(c) - - // The circle spans [-25..125] on both axes. - // It must appear both near zero and near the wrapped end. - require.True(t, hasObjectInCell(g, 0, 0, id)) - require.True(t, hasObjectInCell(g, 0, 5, id)) - require.True(t, hasObjectInCell(g, 5, 0, id)) - require.True(t, hasObjectInCell(g, 5, 5, id)) - - // It also extends into the next cells near the origin. - require.True(t, hasObjectInCell(g, 0, 1, id)) - require.True(t, hasObjectInCell(g, 1, 0, id)) - require.True(t, hasObjectInCell(g, 1, 1, id)) -} - -// TestIndexCircle_NoWrap verifies index Circle No Wrap. -func TestIndexCircle_NoWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - } - - g.indexObject(c) - - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.True(t, hasObjectInCell(g, 2, 3, id)) - require.True(t, hasObjectInCell(g, 3, 2, id)) - require.True(t, hasObjectInCell(g, 3, 3, id)) -} - -// TestIndexCircle_CoversWholeWorldWhenLargerThanWorld verifies index Circle Covers Whole World When Larger Than World. -func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - c := Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 400 * SCALE, - } - - g.indexObject(c) - - for row := 0; row < g.rows; row++ { - for col := 0; col < g.cols; col++ { - require.Truef(t, hasObjectInCell(g, row, col, id), "missing object in row=%d col=%d", row, col) - } - } -} - -// TestIndexLine_HorizontalWrap verifies index Line Horizontal Wrap. -func TestIndexLine_HorizontalWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 590 * SCALE, - Y1: 200 * SCALE, - X2: 10 * SCALE, - Y2: 200 * SCALE, - } - - g.indexObject(l) - - // The shortest torus representation crosses the right/left border. - require.True(t, hasObjectInCell(g, 2, 5, id)) - require.True(t, hasObjectInCell(g, 2, 0, id)) -} - -// TestIndexLine_VerticalWrap verifies index Line Vertical Wrap. -func TestIndexLine_VerticalWrap(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 590 * SCALE, - X2: 200 * SCALE, - Y2: 10 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 5, 2, id)) - require.True(t, hasObjectInCell(g, 0, 2, id)) -} - -// TestIndexLine_DiagonalWrapBothAxes verifies index Line Diagonal Wrap Both Axes. -func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 590 * SCALE, - Y1: 590 * SCALE, - X2: 10 * SCALE, - Y2: 10 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 5, 5, id)) - require.True(t, hasObjectInCell(g, 0, 0, id)) -} - -// TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes verifies index Line Horizontal No Wrap Degenerate B Box Still Indexes. -func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 100 * SCALE, - Y1: 200 * SCALE, - X2: 300 * SCALE, - Y2: 200 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is half-open: [100,300). - // Therefore it occupies columns 1 and 2, but not column 3. - require.True(t, hasObjectInCell(g, 2, 1, id)) - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.False(t, hasObjectInCell(g, 2, 3, id)) -} - -// TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes verifies index Line Vertical No Wrap Degenerate B Box Still Indexes. -func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 200 * SCALE, - Y2: 300 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is half-open: [100,300). - // Therefore it occupies rows 1 and 2, but not row 3. - require.True(t, hasObjectInCell(g, 1, 2, id)) - require.True(t, hasObjectInCell(g, 2, 2, id)) - require.False(t, hasObjectInCell(g, 3, 2, id)) -} - -// TestIndexLine_ZeroLengthIndexesSingleCell verifies index Line Zero Length Indexes Single Cell. -func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 250 * SCALE, - Y1: 350 * SCALE, - X2: 250 * SCALE, - Y2: 350 * SCALE, - } - - g.indexObject(l) - - require.True(t, hasObjectInCell(g, 3, 2, id)) - require.Equal(t, 1, countObjectInGrid(g, id)) -} - -// TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval verifies index Line Exactly On Cell Boundary Uses Half Open Interval. -func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - l := Line{ - Id: id, - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 400 * SCALE, - Y2: 100 * SCALE, - } - - g.indexObject(l) - - // The indexed interval is [200,400), so it must occupy columns 2 and 3 only. - require.True(t, hasObjectInCell(g, 1, 2, id)) - require.True(t, hasObjectInCell(g, 1, 3, id)) - require.False(t, hasObjectInCell(g, 1, 4, id)) -} - -func collectOccupiedCells(g *World, id PrimitiveID) []gridCell { - var cells []gridCell - for row := range g.grid { - for col := range g.grid[row] { - for _, item := range g.grid[row][col] { - if item.ID() == id { - cells = append(cells, gridCell{Row: row, Col: col}) - break - } - } - } - } - return cells -} - -func allGridCells(rows, cols int) []gridCell { - cells := make([]gridCell, 0, rows*cols) - for row := 0; row < rows; row++ { - for col := 0; col < cols; col++ { - cells = append(cells, gridCell{Row: row, Col: col}) - } - } - return cells -} - -func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) { - t.Helper() - - got := collectOccupiedCells(g, id) - - require.ElementsMatchf( - t, - want, - got, - "unexpected indexed cells for object %d", - id, - ) -} - -// TestIndexObject_Point_TableDriven verifies index Object Point Table Driven. -func TestIndexObject_Point_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Point - wantCells []gridCell - }{ - { - name: "point inside world", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 150 * SCALE, - Y: 250 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 1}, - }, - }, - { - name: "point wraps from negative coordinates to last cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: -1, - Y: -1, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - }, - }, - { - name: "point exactly at world boundary wraps to zero cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 600 * SCALE, - Y: 600 * SCALE, - }, - wantCells: []gridCell{ - {Row: 0, Col: 0}, - }, - }, - { - name: "point on cell boundary belongs to that cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Point{ - Id: PrimitiveID(1), - X: 200 * SCALE, - Y: 300 * SCALE, - }, - wantCells: []gridCell{ - {Row: 3, Col: 2}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -// TestIndexObject_Circle_TableDriven verifies index Object Circle Table Driven. -func TestIndexObject_Circle_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Circle - wantCells []gridCell - }{ - { - name: "circle without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 2}, - {Row: 2, Col: 3}, - {Row: 3, Col: 2}, - {Row: 3, Col: 3}, - }, - }, - { - name: "circle wraps across left and top edges", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 50 * SCALE, - Y: 50 * SCALE, - Radius: 75 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - {Row: 5, Col: 0}, - {Row: 5, Col: 1}, - {Row: 0, Col: 5}, - {Row: 0, Col: 0}, - {Row: 0, Col: 1}, - {Row: 1, Col: 5}, - {Row: 1, Col: 0}, - {Row: 1, Col: 1}, - }, - }, - { - name: "circle wraps across right edge only", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 575 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 5}, - {Row: 2, Col: 0}, - {Row: 3, Col: 5}, - {Row: 3, Col: 0}, - }, - }, - { - name: "circle wraps across bottom edge only", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 575 * SCALE, - Radius: 50 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 2}, - {Row: 5, Col: 3}, - {Row: 0, Col: 2}, - {Row: 0, Col: 3}, - }, - }, - { - name: "circle larger than world covers the whole grid", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 400 * SCALE, - }, - wantCells: allGridCells(6, 6), - }, - { - name: "circle touching boundaries exactly uses half-open indexing", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Circle{ - Id: PrimitiveID(1), - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 100 * SCALE, // bbox [200, 400) x [200, 400) - }, - wantCells: []gridCell{ - {Row: 2, Col: 2}, - {Row: 2, Col: 3}, - {Row: 3, Col: 2}, - {Row: 3, Col: 3}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -// TestIndexObject_Line_TableDriven verifies index Object Line Table Driven. -func TestIndexObject_Line_TableDriven(t *testing.T) { - tests := []struct { - name string - worldW int - worldH int - cellSize int - item Line - wantCells []gridCell - }{ - { - name: "horizontal line without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 100 * SCALE, - Y1: 200 * SCALE, - X2: 300 * SCALE, - Y2: 200 * SCALE, - }, - // Half-open interval [100,300), so only cols 1 and 2. - wantCells: []gridCell{ - {Row: 2, Col: 1}, - {Row: 2, Col: 2}, - }, - }, - { - name: "vertical line without wrap", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 200 * SCALE, - Y2: 300 * SCALE, - }, - // Half-open interval [100,300), so only rows 1 and 2. - wantCells: []gridCell{ - {Row: 1, Col: 2}, - {Row: 2, Col: 2}, - }, - }, - { - name: "horizontal line wraps across left right border", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 590 * SCALE, - Y1: 200 * SCALE, - X2: 10 * SCALE, - Y2: 200 * SCALE, - }, - wantCells: []gridCell{ - {Row: 2, Col: 5}, - {Row: 2, Col: 0}, - }, - }, - { - name: "vertical line wraps across top bottom border", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 590 * SCALE, - X2: 200 * SCALE, - Y2: 10 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 2}, - {Row: 0, Col: 2}, - }, - }, - { - name: "diagonal line wraps across both axes", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 590 * SCALE, - Y1: 590 * SCALE, - X2: 10 * SCALE, - Y2: 10 * SCALE, - }, - wantCells: []gridCell{ - {Row: 5, Col: 5}, - {Row: 5, Col: 0}, - {Row: 0, Col: 5}, - {Row: 0, Col: 0}, - }, - }, - { - name: "zero length line indexes a single cell", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 250 * SCALE, - Y1: 350 * SCALE, - X2: 250 * SCALE, - Y2: 350 * SCALE, - }, - wantCells: []gridCell{ - {Row: 3, Col: 2}, - }, - }, - { - name: "line exactly on cell boundaries follows half-open interval", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 200 * SCALE, - Y1: 100 * SCALE, - X2: 400 * SCALE, - Y2: 100 * SCALE, - }, - // [200,400) => cols 2 and 3 only. - wantCells: []gridCell{ - {Row: 1, Col: 2}, - {Row: 1, Col: 3}, - }, - }, - { - name: "diagonal line without wrap indexes its full bbox footprint", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 100 * SCALE, - Y1: 100 * SCALE, - X2: 300 * SCALE, - Y2: 300 * SCALE, - }, - // Indexing is bbox-based, not raster-based. - // The bbox is [100,300) x [100,300), so four cells. - wantCells: []gridCell{ - {Row: 1, Col: 1}, - {Row: 1, Col: 2}, - {Row: 2, Col: 1}, - {Row: 2, Col: 2}, - }, - }, - { - name: "horizontal wrap exactly on borders still indexes both edge cells", - worldW: 600, - worldH: 600, - cellSize: 100 * SCALE, - item: Line{ - Id: PrimitiveID(1), - X1: 600 * SCALE, - Y1: 100 * SCALE, - X2: 0, - Y2: 100 * SCALE, - }, - // After wrapping both endpoints are equivalent to zero-width on the edge. - // The degenerate bbox expansion should still index the first cell only. - wantCells: []gridCell{ - {Row: 1, Col: 0}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := newTestWorld(tt.worldW, tt.worldH) - g.resetGrid(tt.cellSize) - - g.indexObject(tt.item) - - requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) - }) - } -} - -// TestIndexOnViewportChange_RebuildsGridAndIndexesObjects verifies index On Viewport Change Rebuilds Grid And Indexes Objects. -func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) { - g := newTestWorld(600, 400) - - pID := PrimitiveID(1) - cID := PrimitiveID(2) - lID := PrimitiveID(3) - - g.objects[pID] = Point{ - Id: pID, - X: 50 * SCALE, - Y: 50 * SCALE, - } - g.objects[cID] = Circle{ - Id: cID, - X: 300 * SCALE, - Y: 200 * SCALE, - Radius: 50 * SCALE, - } - g.objects[lID] = Line{ - Id: lID, - X1: 590 * SCALE, - Y1: 100 * SCALE, - X2: 10 * SCALE, - Y2: 100 * SCALE, - } - - g.IndexOnViewportChange(500, 300, 1.) - - require.Greater(t, g.cellSize, 0) - require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) - require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) - - require.Greaterf(t, countObjectInGrid(g, pID), 0, "point %s was not indexed", pID) - require.Greaterf(t, countObjectInGrid(g, cID), 0, "circle %s was not indexed", cID) - require.Greaterf(t, countObjectInGrid(g, lID), 0, "line %s was not indexed", lID) -} - -// TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld verifies index On Viewport Change Rebuilds Grid Shape For Non Square World. -func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) { - g := newTestWorld(600, 400) - g.IndexOnViewportChange(500, 300, 1.) - - require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) - require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) - require.Len(t, g.grid, g.rows) - require.Len(t, g.grid[0], g.cols) -} - -// TestIndexOnViewportChange_ReindexesAfterCellSizeChange verifies index On Viewport Change Reindexes After Cell Size Change. -func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) { - g := newTestWorld(600, 600) - - id := PrimitiveID(1) - g.objects[id] = Circle{ - Id: id, - X: 300 * SCALE, - Y: 300 * SCALE, - Radius: 50 * SCALE, - } - - g.IndexOnViewportChange(500, 500, 1.) - firstCellSize := g.cellSize - firstCount := countObjectInGrid(g, id) - - g.IndexOnViewportChange(200, 200, 1.) - secondCellSize := g.cellSize - secondCount := countObjectInGrid(g, id) - - require.NotEqual(t, firstCellSize, secondCellSize) - require.Greater(t, firstCount, 0) - require.Greater(t, secondCount, 0) - - if firstCellSize != secondCellSize && firstCount == secondCount { - t.Logf( - "cell size changed from %d to %d, but the indexed cell count happened to stay equal (%d)", - firstCellSize, - secondCellSize, - firstCount, - ) - } -} - -// TestPrimitiveIndexing_ErrorMessagesStayReadable verifies primitive Indexing Error Messages Stay Readable. -func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { - g := newTestWorld(600, 600) - g.resetGrid(100 * SCALE) - - id := PrimitiveID(1) - p := Point{ - Id: id, - X: 100 * SCALE, - Y: 100 * SCALE, - } - - g.indexObject(p) - - got := collectOccupiedCells(g, id) - require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id)) -} - -// TestPrimitiveIDs verifies primitive I Ds. -func TestPrimitiveIDs(t *testing.T) { - t.Parallel() - - id1 := PrimitiveID(1) - id2 := PrimitiveID(2) - id3 := PrimitiveID(3) - - p := Point{Id: id1} - l := Line{Id: id2} - c := Circle{Id: id3} - - if got := p.ID(); got != id1 { - t.Fatalf("Point.ID() = %v, want %v", got, id1) - } - if got := l.ID(); got != id2 { - t.Fatalf("Line.ID() = %v, want %v", got, id2) - } - if got := c.ID(); got != id3 { - t.Fatalf("Circle.ID() = %v, want %v", got, id3) - } -} - -// TestLineMinMax verifies line Min Max. -func TestLineMinMax(t *testing.T) { - t.Parallel() - - l := Line{ - X1: 7000, Y1: 2000, - X2: 1000, Y2: 9000, - } - - if got := l.MinX(); got != 1000 { - t.Fatalf("Line.MinX() = %d, want 1000", got) - } - if got := l.MaxX(); got != 7000 { - t.Fatalf("Line.MaxX() = %d, want 7000", got) - } - if got := l.MinY(); got != 2000 { - t.Fatalf("Line.MinY() = %d, want 2000", got) - } - if got := l.MaxY(); got != 9000 { - t.Fatalf("Line.MaxY() = %d, want 9000", got) - } -} - -// TestCircleBounds verifies circle Bounds. -func TestCircleBounds(t *testing.T) { - t.Parallel() - - c := Circle{ - X: 4000, - Y: 7000, - Radius: 1500, - } - - if got := c.MinX(); got != 2500 { - t.Fatalf("Circle.MinX() = %d, want 2500", got) - } - if got := c.MaxX(); got != 5500 { - t.Fatalf("Circle.MaxX() = %d, want 5500", got) - } - if got := c.MinY(); got != 5500 { - t.Fatalf("Circle.MinY() = %d, want 5500", got) - } - if got := c.MaxY(); got != 8500 { - t.Fatalf("Circle.MaxY() = %d, want 8500", got) - } -} - -// TestRender_CircleRadiusScale_AffectsRenderedRadiusPx verifies render Circle Radius Scale Affects Rendered Radius Px. -func TestRender_CircleRadiusScale_AffectsRenderedRadiusPx(t *testing.T) { - t.Parallel() - - w := NewWorld(10, 10) - w.resetGrid(2 * SCALE) - - // Ensure index state is initialized so Add triggers rebuild if needed. - w.IndexOnViewportChange(10, 10, 1.0) - - _, err := w.AddCircle(5, 5, 2) // raw radius = 2 units - require.NoError(t, err) - - // scale = 2.0 - require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE)) - - // Reindex explicitly (safe). - w.Reindex() - - 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)) - - circles := d.CommandsByName("AddCircle") - require.NotEmpty(t, circles) - - // AddCircle args: cx, cy, rPx - rPx := circles[0].Args[2] - require.Equal(t, float64(4), rPx, "raw radius=2 with scale=2 => eff radius=4 => rPx=4 at zoom=1") -} diff --git a/go.work b/go.work index 6ca2236..8aee950 100644 --- a/go.work +++ b/go.work @@ -2,7 +2,6 @@ go 1.26.2 use ( ./backend - ./client ./game ./gateway ./integration diff --git a/go.work.sum b/go.work.sum index e7e3f66..e11b85d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -18,6 +18,7 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= @@ -25,6 +26,7 @@ github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9 github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= @@ -41,6 +43,7 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= @@ -53,8 +56,6 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= @@ -66,11 +67,11 @@ github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHu github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= @@ -103,7 +104,6 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= diff --git a/integration/go.mod b/integration/go.mod index 5a806b9..94ce8f4 100644 --- a/integration/go.mod +++ b/integration/go.mod @@ -3,6 +3,7 @@ module galaxy/integration go 1.26.1 require ( + connectrpc.com/connect v1.19.2 galaxy/gateway v0.0.0-00010101000000-000000000000 galaxy/model v0.0.0-00010101000000-000000000000 galaxy/transcoder v0.0.0-00010101000000-000000000000 @@ -10,12 +11,11 @@ require ( github.com/moby/moby/api v1.54.2 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 - google.golang.org/grpc v1.80.0 + golang.org/x/net v0.53.0 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect - connectrpc.com/connect v1.19.2 // indirect dario.cat/mergo v1.0.2 // indirect galaxy/util v0.0.0-00010101000000-000000000000 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -65,10 +65,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/storage/go.mod b/pkg/storage/go.mod index 9965869..031e4be 100644 --- a/pkg/storage/go.mod +++ b/pkg/storage/go.mod @@ -1,3 +1,5 @@ module galaxy/storage go 1.26.0 + +require github.com/google/uuid v1.6.0 diff --git a/pkg/storage/go.sum b/pkg/storage/go.sum new file mode 100644 index 0000000..39ebf3d --- /dev/null +++ b/pkg/storage/go.sum @@ -0,0 +1 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/ui/wasm/go.mod b/ui/wasm/go.mod index 7eae107..2bb6db4 100644 --- a/ui/wasm/go.mod +++ b/ui/wasm/go.mod @@ -4,4 +4,9 @@ go 1.26.0 require galaxy/core v0.0.0 +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect +) + replace galaxy/core => ../core diff --git a/ui/wasm/go.sum b/ui/wasm/go.sum index f4e3748..75190d6 100644 --- a/ui/wasm/go.sum +++ b/ui/wasm/go.sum @@ -1,7 +1,5 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=