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) }