feat: hit on primitives
This commit is contained in:
+168
-46
@@ -3,25 +3,41 @@ package world
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
errBadCoordinate = errors.New("invalid coordinates")
|
||||
errBadRadius = errors.New("invalid radius")
|
||||
errNoSuchObject = errors.New("no such object")
|
||||
errIDExhausted = errors.New("primitive id exhausted")
|
||||
)
|
||||
|
||||
type indexState struct {
|
||||
initialized bool
|
||||
viewportW int
|
||||
viewportH int
|
||||
zoomFp int
|
||||
}
|
||||
|
||||
// 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
|
||||
W, H int // Fixed-point world size.
|
||||
grid [][][]MapItem
|
||||
cellSize int
|
||||
rows, cols int
|
||||
objects map[PrimitiveID]MapItem
|
||||
styles *StyleTable
|
||||
|
||||
// PrimitiveID allocator state.
|
||||
nextID PrimitiveID
|
||||
freeIDs []PrimitiveID
|
||||
|
||||
// Index dirty flag for add/remove updates.
|
||||
indexDirty bool
|
||||
index indexState
|
||||
|
||||
renderState rendererIncrementalState
|
||||
styles *StyleTable
|
||||
}
|
||||
|
||||
// NewWorld constructs a new world with the given real dimensions.
|
||||
@@ -34,11 +50,36 @@ func NewWorld(width, height int) *World {
|
||||
W: width * SCALE,
|
||||
H: height * SCALE,
|
||||
cellSize: 1,
|
||||
objects: make(map[uuid.UUID]MapItem),
|
||||
objects: make(map[PrimitiveID]MapItem),
|
||||
styles: NewStyleTable(),
|
||||
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 (g *World) allocID() (PrimitiveID, error) {
|
||||
if n := len(g.freeIDs); n > 0 {
|
||||
id := g.freeIDs[n-1]
|
||||
g.freeIDs = g.freeIDs[:n-1]
|
||||
return id, nil
|
||||
}
|
||||
if g.nextID == PrimitiveID(^uint32(0)) {
|
||||
return 0, errIDExhausted
|
||||
}
|
||||
id := g.nextID
|
||||
g.nextID++
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// freeID returns an id back to the pool. It is safe to call only after the object is removed.
|
||||
func (g *World) freeID(id PrimitiveID) {
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
g.freeIDs = append(g.freeIDs, id)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -63,14 +104,52 @@ func (g *World) AddStylePoint(override StyleOverride) StyleID {
|
||||
return g.styles.AddDerived(StyleIDDefaultPoint, override)
|
||||
}
|
||||
|
||||
// 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 (g *World) Remove(id PrimitiveID) error {
|
||||
if _, ok := g.objects[id]; !ok {
|
||||
return errNoSuchObject
|
||||
}
|
||||
delete(g.objects, id)
|
||||
g.freeID(id)
|
||||
|
||||
g.indexDirty = true
|
||||
g.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 (g *World) Reindex() {
|
||||
g.indexDirty = true
|
||||
g.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 (g *World) rebuildIndexFromLastState() {
|
||||
if !g.indexDirty {
|
||||
return
|
||||
}
|
||||
if !g.index.initialized {
|
||||
return
|
||||
}
|
||||
if g.index.viewportW <= 0 || g.index.viewportH <= 0 || g.index.zoomFp <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp)
|
||||
g.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 (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
|
||||
func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) {
|
||||
xf := fixedPoint(x)
|
||||
yf := fixedPoint(y)
|
||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
return 0, errBadCoordinate
|
||||
}
|
||||
|
||||
o := defaultPointOptions()
|
||||
@@ -81,29 +160,38 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) {
|
||||
}
|
||||
styleID := g.resolvePointStyleID(o)
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Point{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
id, err := g.allocID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
g.objects[id] = Point{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
HitSlopPx: o.HitSlopPx,
|
||||
}
|
||||
|
||||
g.indexDirty = true
|
||||
g.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 (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error) {
|
||||
func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) {
|
||||
xf := fixedPoint(x)
|
||||
yf := fixedPoint(y)
|
||||
|
||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
return 0, errBadCoordinate
|
||||
}
|
||||
if r < 0 {
|
||||
return uuid.Nil, errBadRadius
|
||||
return 0, errBadRadius
|
||||
}
|
||||
|
||||
o := defaultCircleOptions()
|
||||
@@ -114,32 +202,41 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error)
|
||||
}
|
||||
styleID := g.resolveCircleStyleID(o)
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Circle{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Radius: fixedPoint(r),
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
id, err := g.allocID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
g.objects[id] = Circle{
|
||||
Id: id,
|
||||
X: xf,
|
||||
Y: yf,
|
||||
Radius: fixedPoint(r),
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
HitSlopPx: o.HitSlopPx,
|
||||
}
|
||||
|
||||
g.indexDirty = true
|
||||
g.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 (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, error) {
|
||||
func (g *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 := g.checkCoordinate(x1f, y1f); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
return 0, errBadCoordinate
|
||||
}
|
||||
if ok := g.checkCoordinate(x2f, y2f); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
return 0, errBadCoordinate
|
||||
}
|
||||
|
||||
o := defaultLineOptions()
|
||||
@@ -150,16 +247,25 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err
|
||||
}
|
||||
styleID := g.resolveLineStyleID(o)
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Line{
|
||||
Id: id,
|
||||
X1: x1f,
|
||||
Y1: y1f,
|
||||
X2: x2f,
|
||||
Y2: y2f,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
id, err := g.allocID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
g.objects[id] = Line{
|
||||
Id: id,
|
||||
X1: x1f,
|
||||
Y1: y1f,
|
||||
X2: x2f,
|
||||
Y2: y2f,
|
||||
Priority: o.Priority,
|
||||
StyleID: styleID,
|
||||
HitSlopPx: o.HitSlopPx,
|
||||
}
|
||||
|
||||
g.indexDirty = true
|
||||
g.rebuildIndexFromLastState()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +312,10 @@ func (g *World) worldToCellY(y int) int {
|
||||
// resetGrid recreates the spatial grid with the given cell size
|
||||
// and clears all previous indexing state.
|
||||
func (g *World) resetGrid(cellSize int) {
|
||||
if cellSize <= 0 {
|
||||
panic("resetGrid: invalid cell size")
|
||||
}
|
||||
|
||||
g.cellSize = cellSize
|
||||
g.cols = ceilDiv(g.W, g.cellSize)
|
||||
g.rows = ceilDiv(g.H, g.cellSize)
|
||||
@@ -277,12 +387,24 @@ func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// IndexOnViewportChange is called when UI window sizes are changed.
|
||||
// cameraZoom is float64, converted inside world to fixed-point.
|
||||
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
||||
cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom)
|
||||
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp)
|
||||
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
|
||||
|
||||
// Remember params for autonomous reindex after Add/Remove.
|
||||
g.index.initialized = true
|
||||
g.index.viewportW = viewportWidthPx
|
||||
g.index.viewportH = viewportHeightPx
|
||||
g.index.zoomFp = zoomFp
|
||||
|
||||
g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||
g.indexDirty = false
|
||||
}
|
||||
|
||||
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
|
||||
func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
|
||||
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
|
||||
|
||||
cellsAcrossMin := 8
|
||||
visibleMin := min(worldWidth, worldHeight)
|
||||
|
||||
Reference in New Issue
Block a user