package world import "errors" 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 } 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 }