package world import ( "errors" "fmt" "github.com/google/uuid" ) var ( errBadCoordinate = errors.New("invalid coordinates") errBadRadius = errors.New("invalid radius") ) // 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[uuid.UUID]MapItem renderState rendererIncrementalState } // 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[uuid.UUID]MapItem), } } // checkCoordinate reports whether the fixed-point coordinate (xf, yf) // lies inside the world bounds: [0, W) x [0, H). func (g *World) checkCoordinate(xf, yf int) bool { if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H { return false } return true } // 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 (g *World) AddPoint(x, y float64) (uuid.UUID, error) { xf := fixedPoint(x) yf := fixedPoint(y) if ok := g.checkCoordinate(xf, yf); !ok { return uuid.Nil, errBadCoordinate } id := uuid.New() g.objects[id] = Point{Id: id, X: xf, Y: yf} 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 (g *World) AddCircle(x, y, r float64) (uuid.UUID, error) { xf := fixedPoint(x) yf := fixedPoint(y) rf := fixedPoint(r) if ok := g.checkCoordinate(xf, yf); !ok { return uuid.Nil, errBadCoordinate } if rf < 0 { return uuid.Nil, errBadRadius } id := uuid.New() g.objects[id] = Circle{Id: id, X: xf, Y: yf, Radius: rf} 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 (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) { x1f := fixedPoint(x1) y1f := fixedPoint(y1) x2f := fixedPoint(x2) y2f := fixedPoint(y2) if ok := g.checkCoordinate(x1f, y1f); !ok { return uuid.Nil, errBadCoordinate } if ok := g.checkCoordinate(x2f, y2f); !ok { return uuid.Nil, errBadCoordinate } id := uuid.New() g.objects[id] = Line{Id: id, X1: x1f, Y1: y1f, X2: x2f, Y2: y2f} return id, nil } // worldToCellX converts a fixed-point X coordinate to a grid column index. func (g *World) worldToCellX(x int) int { return worldToCell(x, g.W, g.cols, g.cellSize) } // worldToCellY converts a fixed-point Y coordinate to a grid row index. func (g *World) worldToCellY(y int) int { return worldToCell(y, g.H, g.rows, g.cellSize) } // resetGrid recreates the spatial grid with the given cell size // and clears all previous indexing state. func (g *World) resetGrid(cellSize int) { g.cellSize = cellSize g.cols = ceilDiv(g.W, g.cellSize) g.rows = ceilDiv(g.H, g.cellSize) g.grid = make([][][]MapItem, g.rows) for row := range g.grid { g.grid[row] = make([][]MapItem, g.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 (g *World) indexObject(o MapItem) { switch mapItem := o.(type) { case Point: col := g.worldToCellX(mapItem.X) row := g.worldToCellY(mapItem.Y) g.grid[row][col] = append(g.grid[row][col], mapItem) case Line: x1 := mapItem.X1 y1 := mapItem.Y1 x2 := mapItem.X2 y2 := mapItem.Y2 x1, x2 = shortestWrappedDelta(x1, x2, g.W) y1, y2 = shortestWrappedDelta(y1, y2, g.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++ } g.indexBBox(mapItem, minX, maxX, minY, maxY) case Circle: g.indexBBox(mapItem, mapItem.MinX(), mapItem.MaxX(), mapItem.MinY(), mapItem.MaxY()) 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 (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY) for _, r := range rects { colStart := g.worldToCellX(r.minX) colEnd := g.worldToCellX(r.maxX - 1) rowStart := g.worldToCellY(r.minY) rowEnd := g.worldToCellY(r.maxY - 1) for col := colStart; col <= colEnd; col++ { for row := rowStart; row <= rowEnd; row++ { g.grid[row][col] = append(g.grid[row][col], o) } } } } // IndexOnViewportChange rebuilds the grid for a new viewport size and zoom. // The zoom is provided by the UI as a real multiplier and is converted // to fixed-point inside the function. func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) { cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom) worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp) cellsAcrossMin := 8 visibleMin := min(worldWidth, worldHeight) cellSize := visibleMin / cellsAcrossMin cellSize = clamp(cellSize, cellSizeMin, cellSizeMax) g.resetGrid(cellSize) for _, o := range g.objects { g.indexObject(o) } }