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") ) type indexState struct { initialized bool viewportW int viewportH int zoomFp int } 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. // Step 1 behavior: // - Does NOT mutate built-in default styles (1/2/3). // - Materializes three theme default styles as new StyleIDs in the style table. // - New objects (and later, theme-relative ones) can use these IDs. // - Forces next render to full redraw. func (w *World) SetTheme(theme StyleTheme) { if theme == nil { theme = DefaultTheme{} } // fmt.Println("current theme:", w.theme.ID()) w.theme = theme // fmt.Println("new theme:", w.theme.ID()) // 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.reresolveThemeManagedStyles() // Full redraw to apply new background and base styles. w.renderState.Reset() // w.forceFullRedraw = true 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 } } func (w *World) reresolveThemeManagedStyles() { 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("reresolveThemeManagedStyles: unknown item type") } } w.ForceFullRedrawNext() } 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.indexOnViewportChangeZoomFp(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 } // func (g *World) resolvePointStyleID(o PointOptions) StyleID { // if o.hasStyleID { // return o.StyleID // } // if o.Override.IsZero() { // return StyleIDDefaultPoint // } // return g.styles.AddDerived(StyleIDDefaultPoint, o.Override) // } // func (g *World) resolveCircleStyleID(o CircleOptions) StyleID { // if o.hasStyleID { // return o.StyleID // } // if o.Override.IsZero() { // return StyleIDDefaultCircle // } // return g.styles.AddDerived(StyleIDDefaultCircle, o.Override) // } // func (g *World) resolveLineStyleID(o LineOptions) StyleID { // if o.hasStyleID { // return o.StyleID // } // if o.Override.IsZero() { // return StyleIDDefaultLine // } // return g.styles.AddDerived(StyleIDDefaultLine, o.Override) // } // 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.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp) w.indexDirty = false } // indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom. func (w *World) indexOnViewportChangeZoomFp(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) }