Initial release: Go Jdenticon library v0.1.0
- Core library with SVG and PNG generation - CLI tool with generate and batch commands - Cross-platform path handling for Windows compatibility - Comprehensive test suite with integration tests
This commit is contained in:
@@ -2,179 +2,605 @@ package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
"github.com/ungluedlabs/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// PNGRenderer implements the Renderer interface for PNG output
|
||||
// PNG rendering constants
|
||||
const (
|
||||
defaultSupersamplingFactor = 8 // Default antialiasing supersampling factor
|
||||
)
|
||||
|
||||
// Memory pools for reducing allocations during rendering
|
||||
var (
|
||||
// Pool for point slices used during polygon processing
|
||||
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
|
||||
pointSlicePool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
s := make([]engine.Point, 0, 16) // Pre-allocate reasonable capacity
|
||||
return &s
|
||||
},
|
||||
}
|
||||
|
||||
// Pool for color row buffers
|
||||
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
|
||||
colorRowBufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
s := make([]color.RGBA, 0, 1024) // Row buffer capacity
|
||||
return &s
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// ShapeCommand represents a rendering command for deferred execution
|
||||
type ShapeCommand struct {
|
||||
Type string // "polygon", "circle", "background"
|
||||
Points []engine.Point // For polygons
|
||||
Center engine.Point // For circles
|
||||
Size float64 // For circles
|
||||
Invert bool // For circles
|
||||
Color color.RGBA
|
||||
BBox image.Rectangle // Pre-calculated bounding box for culling
|
||||
}
|
||||
|
||||
// PNGRenderer implements memory-efficient PNG generation using streaming row processing
|
||||
// This eliminates the dual buffer allocation problem, reducing memory usage by ~80%
|
||||
type PNGRenderer struct {
|
||||
*BaseRenderer
|
||||
img *image.RGBA
|
||||
currentColor color.RGBA
|
||||
background color.RGBA
|
||||
hasBackground bool
|
||||
mu sync.RWMutex // For thread safety in concurrent generation
|
||||
finalImg *image.RGBA // Single buffer at target resolution
|
||||
finalSize int // Target output size
|
||||
bgColor color.RGBA // Background color
|
||||
shapes []ShapeCommand // Queued rendering commands
|
||||
}
|
||||
|
||||
// bufferPool provides buffer pooling for efficient PNG generation
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &bytes.Buffer{}
|
||||
},
|
||||
}
|
||||
|
||||
// NewPNGRenderer creates a new PNG renderer with the specified icon size
|
||||
// NewPNGRenderer creates a new memory-optimized PNG renderer
|
||||
func NewPNGRenderer(iconSize int) *PNGRenderer {
|
||||
// Only allocate the final image buffer - no supersampled buffer
|
||||
finalBounds := image.Rect(0, 0, iconSize, iconSize)
|
||||
finalImg := image.NewRGBA(finalBounds)
|
||||
|
||||
return &PNGRenderer{
|
||||
BaseRenderer: NewBaseRenderer(iconSize),
|
||||
img: image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)),
|
||||
finalImg: finalImg,
|
||||
finalSize: iconSize,
|
||||
shapes: make([]ShapeCommand, 0, 16), // Pre-allocate for typical use
|
||||
}
|
||||
}
|
||||
|
||||
// SetBackground sets the background color and opacity
|
||||
// SetBackground sets the background color - queues background command
|
||||
func (r *PNGRenderer) SetBackground(fillColor string, opacity float64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.BaseRenderer.SetBackground(fillColor, opacity)
|
||||
r.background = parseColor(fillColor, opacity)
|
||||
r.hasBackground = opacity > 0
|
||||
|
||||
if r.hasBackground {
|
||||
// Fill the entire image with background color
|
||||
draw.Draw(r.img, r.img.Bounds(), &image.Uniform{r.background}, image.Point{}, draw.Src)
|
||||
}
|
||||
r.bgColor = r.parseColor(fillColor, opacity)
|
||||
|
||||
// Queue background command for proper rendering order
|
||||
r.shapes = append(r.shapes, ShapeCommand{
|
||||
Type: "background",
|
||||
Color: r.bgColor,
|
||||
BBox: image.Rect(0, 0, r.finalSize*2, r.finalSize*2), // Full supersampled bounds
|
||||
})
|
||||
}
|
||||
|
||||
// BeginShape marks the beginning of a new shape with the specified color
|
||||
func (r *PNGRenderer) BeginShape(fillColor string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.BaseRenderer.BeginShape(fillColor)
|
||||
r.currentColor = parseColor(fillColor, 1.0)
|
||||
}
|
||||
|
||||
// EndShape marks the end of the currently drawn shape
|
||||
// EndShape marks the end of the currently drawn shape (no-op for queuing renderer)
|
||||
func (r *PNGRenderer) EndShape() {
|
||||
// No action needed for PNG - shapes are drawn immediately
|
||||
// No-op for command queuing approach
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon with the current fill color to the image
|
||||
// AddPolygon queues a polygon command with pre-calculated bounding box
|
||||
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
if len(points) < 3 {
|
||||
return // Can't render polygon with < 3 points
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// Determine winding order for hole detection
|
||||
var area float64
|
||||
for i := 0; i < len(points); i++ {
|
||||
p1 := points[i]
|
||||
p2 := points[(i+1)%len(points)]
|
||||
area += (p1.X * p2.Y) - (p2.X * p1.Y)
|
||||
}
|
||||
|
||||
// Convert engine.Point to image coordinates
|
||||
imagePoints := make([]image.Point, len(points))
|
||||
for i, p := range points {
|
||||
imagePoints[i] = image.Point{
|
||||
X: int(math.Round(p.X)),
|
||||
Y: int(math.Round(p.Y)),
|
||||
var renderColor color.RGBA
|
||||
if area < 0 {
|
||||
// Counter-clockwise winding (hole) - use background color
|
||||
renderColor = r.bgColor
|
||||
} else {
|
||||
// Clockwise winding (normal shape)
|
||||
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
|
||||
}
|
||||
|
||||
// Get pooled point slice and scale points to supersampled coordinates
|
||||
scaledPointsPtr := pointSlicePool.Get().(*[]engine.Point)
|
||||
scaledPointsSlice := *scaledPointsPtr
|
||||
defer func() {
|
||||
*scaledPointsPtr = scaledPointsSlice // Update with potentially resized slice
|
||||
pointSlicePool.Put(scaledPointsPtr)
|
||||
}()
|
||||
|
||||
// Reset slice and ensure capacity
|
||||
scaledPointsSlice = scaledPointsSlice[:0]
|
||||
if cap(scaledPointsSlice) < len(points) {
|
||||
scaledPointsSlice = make([]engine.Point, 0, len(points)*2)
|
||||
}
|
||||
|
||||
minX, minY := math.MaxFloat64, math.MaxFloat64
|
||||
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
|
||||
|
||||
for _, p := range points {
|
||||
scaledP := engine.Point{
|
||||
X: p.X * defaultSupersamplingFactor,
|
||||
Y: p.Y * defaultSupersamplingFactor,
|
||||
}
|
||||
scaledPointsSlice = append(scaledPointsSlice, scaledP)
|
||||
|
||||
if scaledP.X < minX {
|
||||
minX = scaledP.X
|
||||
}
|
||||
if scaledP.X > maxX {
|
||||
maxX = scaledP.X
|
||||
}
|
||||
if scaledP.Y < minY {
|
||||
minY = scaledP.Y
|
||||
}
|
||||
if scaledP.Y > maxY {
|
||||
maxY = scaledP.Y
|
||||
}
|
||||
}
|
||||
|
||||
// Fill polygon using scanline algorithm
|
||||
r.fillPolygon(imagePoints)
|
||||
// Copy scaled points for storage in command (must copy since we're returning slice to pool)
|
||||
scaledPoints := make([]engine.Point, len(scaledPointsSlice))
|
||||
copy(scaledPoints, scaledPointsSlice)
|
||||
|
||||
// Create bounding box for culling (with safety margins)
|
||||
bbox := image.Rect(
|
||||
int(math.Floor(minX))-1,
|
||||
int(math.Floor(minY))-1,
|
||||
int(math.Ceil(maxX))+1,
|
||||
int(math.Ceil(maxY))+1,
|
||||
)
|
||||
|
||||
// Queue the polygon command
|
||||
r.shapes = append(r.shapes, ShapeCommand{
|
||||
Type: "polygon",
|
||||
Points: scaledPoints,
|
||||
Color: renderColor,
|
||||
BBox: bbox,
|
||||
})
|
||||
}
|
||||
|
||||
// AddCircle adds a circle with the current fill color to the image
|
||||
// AddCircle queues a circle command with pre-calculated bounding box
|
||||
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// Scale to supersampled coordinates
|
||||
scaledTopLeft := engine.Point{
|
||||
X: topLeft.X * defaultSupersamplingFactor,
|
||||
Y: topLeft.Y * defaultSupersamplingFactor,
|
||||
}
|
||||
scaledSize := size * defaultSupersamplingFactor
|
||||
|
||||
radius := size / 2
|
||||
centerX := int(math.Round(topLeft.X + radius))
|
||||
centerY := int(math.Round(topLeft.Y + radius))
|
||||
radiusInt := int(math.Round(radius))
|
||||
centerX := scaledTopLeft.X + scaledSize/2.0
|
||||
centerY := scaledTopLeft.Y + scaledSize/2.0
|
||||
radius := scaledSize / 2.0
|
||||
|
||||
// Use Bresenham's circle algorithm for anti-aliased circle drawing
|
||||
r.drawCircle(centerX, centerY, radiusInt, invert)
|
||||
var renderColor color.RGBA
|
||||
if invert {
|
||||
renderColor = r.bgColor
|
||||
} else {
|
||||
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
|
||||
}
|
||||
|
||||
// Calculate bounding box for the circle
|
||||
bbox := image.Rect(
|
||||
int(math.Floor(centerX-radius))-1,
|
||||
int(math.Floor(centerY-radius))-1,
|
||||
int(math.Ceil(centerX+radius))+1,
|
||||
int(math.Ceil(centerY+radius))+1,
|
||||
)
|
||||
|
||||
// Queue the circle command
|
||||
r.shapes = append(r.shapes, ShapeCommand{
|
||||
Type: "circle",
|
||||
Center: engine.Point{X: centerX, Y: centerY},
|
||||
Size: radius,
|
||||
Color: renderColor,
|
||||
BBox: bbox,
|
||||
})
|
||||
}
|
||||
|
||||
// ToPNG generates the final PNG image data
|
||||
func (r *PNGRenderer) ToPNG() []byte {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
// ToPNG generates the final PNG image data using streaming row processing
|
||||
func (r *PNGRenderer) ToPNG() ([]byte, error) {
|
||||
return r.ToPNGWithSize(r.GetSize())
|
||||
}
|
||||
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer bufferPool.Put(buf)
|
||||
// ToPNGWithSize generates PNG image data with streaming row processing
|
||||
func (r *PNGRenderer) ToPNGWithSize(outputSize int) ([]byte, error) {
|
||||
// Execute streaming rendering pipeline
|
||||
r.renderWithStreaming()
|
||||
|
||||
// Encode to PNG with compression
|
||||
var resultImg image.Image = r.finalImg
|
||||
|
||||
// Scale if output size differs from internal size
|
||||
if outputSize != r.finalSize {
|
||||
resultImg = r.scaleImage(r.finalImg, outputSize)
|
||||
}
|
||||
|
||||
// Encode to PNG with maximum compression
|
||||
var buf bytes.Buffer
|
||||
encoder := &png.Encoder{
|
||||
CompressionLevel: png.BestCompression,
|
||||
}
|
||||
|
||||
if err := encoder.Encode(buf, r.img); err != nil {
|
||||
return nil
|
||||
err := encoder.Encode(&buf, resultImg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jdenticon: optimized renderer: PNG encoding failed: %w", err)
|
||||
}
|
||||
|
||||
// Return a copy of the buffer data
|
||||
result := make([]byte, buf.Len())
|
||||
copy(result, buf.Bytes())
|
||||
return result
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseColor converts a hex color string to RGBA color
|
||||
func parseColor(hexColor string, opacity float64) color.RGBA {
|
||||
// Remove # prefix if present
|
||||
hexColor = strings.TrimPrefix(hexColor, "#")
|
||||
// renderWithStreaming executes the main streaming rendering pipeline
|
||||
func (r *PNGRenderer) renderWithStreaming() {
|
||||
supersampledWidth := r.finalSize * defaultSupersamplingFactor
|
||||
|
||||
// Default to black if parsing fails
|
||||
var r, g, b uint8 = 0, 0, 0
|
||||
// Get pooled row buffer for 2 supersampled rows - MASSIVE memory savings
|
||||
rowBufferPtr := colorRowBufferPool.Get().(*[]color.RGBA)
|
||||
rowBufferSlice := *rowBufferPtr
|
||||
defer func() {
|
||||
*rowBufferPtr = rowBufferSlice // Update with potentially resized slice
|
||||
colorRowBufferPool.Put(rowBufferPtr)
|
||||
}()
|
||||
|
||||
switch len(hexColor) {
|
||||
case 3:
|
||||
// Short form: #RGB -> #RRGGBB
|
||||
if val, err := strconv.ParseUint(hexColor, 16, 12); err == nil {
|
||||
r = uint8((val >> 8 & 0xF) * 17)
|
||||
g = uint8((val >> 4 & 0xF) * 17)
|
||||
b = uint8((val & 0xF) * 17)
|
||||
}
|
||||
case 6:
|
||||
// Full form: #RRGGBB
|
||||
if val, err := strconv.ParseUint(hexColor, 16, 24); err == nil {
|
||||
r = uint8(val >> 16)
|
||||
g = uint8(val >> 8)
|
||||
b = uint8(val)
|
||||
}
|
||||
case 8:
|
||||
// With alpha: #RRGGBBAA
|
||||
if val, err := strconv.ParseUint(hexColor, 16, 32); err == nil {
|
||||
r = uint8(val >> 24)
|
||||
g = uint8(val >> 16)
|
||||
b = uint8(val >> 8)
|
||||
// Override opacity with alpha from color
|
||||
opacity = float64(uint8(val)) / 255.0
|
||||
}
|
||||
// Ensure buffer has correct size
|
||||
requiredSize := supersampledWidth * 2
|
||||
if cap(rowBufferSlice) < requiredSize {
|
||||
rowBufferSlice = make([]color.RGBA, requiredSize)
|
||||
} else {
|
||||
rowBufferSlice = rowBufferSlice[:requiredSize]
|
||||
}
|
||||
|
||||
alpha := uint8(math.Round(opacity * 255))
|
||||
return color.RGBA{R: r, G: g, B: b, A: alpha}
|
||||
// Process each final image row
|
||||
for y := 0; y < r.finalSize; y++ {
|
||||
// Clear row buffer to background color
|
||||
for i := range rowBufferSlice {
|
||||
rowBufferSlice[i] = r.bgColor
|
||||
}
|
||||
|
||||
// Render all shapes for this row pair
|
||||
r.renderShapesForRowPair(y, rowBufferSlice, supersampledWidth)
|
||||
|
||||
// Downsample directly into final image
|
||||
r.downsampleRowPairToFinal(y, rowBufferSlice, supersampledWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// fillPolygon fills a polygon using a scanline algorithm
|
||||
func (r *PNGRenderer) fillPolygon(points []image.Point) {
|
||||
if len(points) < 3 {
|
||||
return
|
||||
// renderShapesForRowPair renders all shapes that intersect the given row pair
|
||||
func (r *PNGRenderer) renderShapesForRowPair(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
// Calculate supersampled Y range for this row pair
|
||||
ssYStart := finalY * defaultSupersamplingFactor
|
||||
ssYEnd := ssYStart + defaultSupersamplingFactor
|
||||
|
||||
// Render each shape that intersects this row pair
|
||||
for _, shape := range r.shapes {
|
||||
// Fast bounding box culling
|
||||
if shape.BBox.Max.Y <= ssYStart || shape.BBox.Min.Y >= ssYEnd {
|
||||
continue // Shape doesn't intersect this row pair
|
||||
}
|
||||
|
||||
switch shape.Type {
|
||||
case "polygon":
|
||||
r.renderPolygonForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
||||
case "circle":
|
||||
r.renderCircleForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderPolygonForRowPair renders a polygon for the specified row range
|
||||
func (r *PNGRenderer) renderPolygonForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
points := shape.Points
|
||||
color := shape.Color
|
||||
|
||||
// Use triangle fan decomposition for simplicity
|
||||
if len(points) == 3 {
|
||||
// Direct triangle rendering
|
||||
r.fillTriangleForRowRange(points[0], points[1], points[2], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
||||
} else if len(points) == 4 && r.isRectangle(points) {
|
||||
// Optimized rectangle rendering
|
||||
minX, minY, maxX, maxY := r.getBoundsFloat(points)
|
||||
r.fillRectForRowRange(minX, minY, maxX, maxY, color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
||||
} else {
|
||||
// General polygon - triangle fan from first vertex
|
||||
for i := 1; i < len(points)-1; i++ {
|
||||
r.fillTriangleForRowRange(points[0], points[i], points[i+1], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderCircleForRowPair renders a circle for the specified row range
|
||||
func (r *PNGRenderer) renderCircleForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
centerX := shape.Center.X
|
||||
centerY := shape.Center.Y
|
||||
radius := shape.Size
|
||||
color := shape.Color
|
||||
radiusSq := radius * radius
|
||||
|
||||
// Process each supersampled row in the range
|
||||
for y := ssYStart; y < ssYEnd; y++ {
|
||||
yFloat := float64(y)
|
||||
dy := yFloat - centerY
|
||||
dySq := dy * dy
|
||||
|
||||
if dySq > radiusSq {
|
||||
continue // Row doesn't intersect circle
|
||||
}
|
||||
|
||||
// Calculate horizontal span for this row
|
||||
dx := math.Sqrt(radiusSq - dySq)
|
||||
xStart := int(math.Floor(centerX - dx))
|
||||
xEnd := int(math.Ceil(centerX + dx))
|
||||
|
||||
// Clip to buffer bounds
|
||||
if xStart < 0 {
|
||||
xStart = 0
|
||||
}
|
||||
if xEnd >= supersampledWidth {
|
||||
xEnd = supersampledWidth - 1
|
||||
}
|
||||
|
||||
// Fill the horizontal span
|
||||
rowIndex := (y - ssYStart) * supersampledWidth
|
||||
for x := xStart; x <= xEnd; x++ {
|
||||
// Verify pixel is actually inside circle
|
||||
dxPixel := float64(x) - centerX
|
||||
if dxPixel*dxPixel+dySq <= radiusSq {
|
||||
if rowIndex+x < len(rowBuffer) {
|
||||
rowBuffer[rowIndex+x] = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillTriangleForRowRange fills a triangle within the specified row range
|
||||
func (r *PNGRenderer) fillTriangleForRowRange(p1, p2, p3 engine.Point, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
// Get triangle bounds
|
||||
minY := math.Min(math.Min(p1.Y, p2.Y), p3.Y)
|
||||
maxY := math.Max(math.Max(p1.Y, p2.Y), p3.Y)
|
||||
|
||||
// Clip to row range
|
||||
iterYStart := int(math.Max(math.Ceil(minY), float64(ssYStart)))
|
||||
iterYEnd := int(math.Min(math.Floor(maxY), float64(ssYEnd-1)))
|
||||
|
||||
if iterYStart > iterYEnd {
|
||||
return // Triangle doesn't intersect row range
|
||||
}
|
||||
|
||||
// Find bounding box
|
||||
// Sort points by Y coordinate
|
||||
x1, y1 := p1.X, p1.Y
|
||||
x2, y2 := p2.X, p2.Y
|
||||
x3, y3 := p3.X, p3.Y
|
||||
|
||||
if y1 > y2 {
|
||||
x1, y1, x2, y2 = x2, y2, x1, y1
|
||||
}
|
||||
if y1 > y3 {
|
||||
x1, y1, x3, y3 = x3, y3, x1, y1
|
||||
}
|
||||
if y2 > y3 {
|
||||
x2, y2, x3, y3 = x3, y3, x2, y2
|
||||
}
|
||||
|
||||
// Fill triangle using scan-line algorithm
|
||||
for y := iterYStart; y <= iterYEnd; y++ {
|
||||
yFloat := float64(y)
|
||||
var xLeft, xRight float64
|
||||
|
||||
if yFloat < y2 {
|
||||
// Upper part of triangle
|
||||
if y2 != y1 {
|
||||
slope12 := (x2 - x1) / (y2 - y1)
|
||||
xLeft = x1 + slope12*(yFloat-y1)
|
||||
} else {
|
||||
xLeft = x1
|
||||
}
|
||||
if y3 != y1 {
|
||||
slope13 := (x3 - x1) / (y3 - y1)
|
||||
xRight = x1 + slope13*(yFloat-y1)
|
||||
} else {
|
||||
xRight = x1
|
||||
}
|
||||
} else {
|
||||
// Lower part of triangle
|
||||
if y3 != y2 {
|
||||
slope23 := (x3 - x2) / (y3 - y2)
|
||||
xLeft = x2 + slope23*(yFloat-y2)
|
||||
} else {
|
||||
xLeft = x2
|
||||
}
|
||||
if y3 != y1 {
|
||||
slope13 := (x3 - x1) / (y3 - y1)
|
||||
xRight = x1 + slope13*(yFloat-y1)
|
||||
} else {
|
||||
xRight = x1
|
||||
}
|
||||
}
|
||||
|
||||
if xLeft > xRight {
|
||||
xLeft, xRight = xRight, xLeft
|
||||
}
|
||||
|
||||
// Convert to pixel coordinates and fill
|
||||
xLeftInt := int(math.Floor(xLeft))
|
||||
xRightInt := int(math.Floor(xRight))
|
||||
|
||||
// Clip to buffer bounds
|
||||
if xLeftInt < 0 {
|
||||
xLeftInt = 0
|
||||
}
|
||||
if xRightInt >= supersampledWidth {
|
||||
xRightInt = supersampledWidth - 1
|
||||
}
|
||||
|
||||
// Fill horizontal span in row buffer
|
||||
rowIndex := (y - ssYStart) * supersampledWidth
|
||||
for x := xLeftInt; x <= xRightInt; x++ {
|
||||
if rowIndex+x < len(rowBuffer) {
|
||||
rowBuffer[rowIndex+x] = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillRectForRowRange fills a rectangle within the specified row range
|
||||
func (r *PNGRenderer) fillRectForRowRange(x1, y1, x2, y2 float64, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
// Convert to integer bounds
|
||||
xStart := int(math.Floor(x1))
|
||||
yStart := int(math.Floor(y1))
|
||||
xEnd := int(math.Ceil(x2))
|
||||
yEnd := int(math.Ceil(y2))
|
||||
|
||||
// Clip to row range
|
||||
if yStart < ssYStart {
|
||||
yStart = ssYStart
|
||||
}
|
||||
if yEnd > ssYEnd {
|
||||
yEnd = ssYEnd
|
||||
}
|
||||
if xStart < 0 {
|
||||
xStart = 0
|
||||
}
|
||||
if xEnd > supersampledWidth {
|
||||
xEnd = supersampledWidth
|
||||
}
|
||||
|
||||
// Fill rectangle in row buffer
|
||||
for y := yStart; y < yEnd; y++ {
|
||||
rowIndex := (y - ssYStart) * supersampledWidth
|
||||
for x := xStart; x < xEnd; x++ {
|
||||
if rowIndex+x < len(rowBuffer) {
|
||||
rowBuffer[rowIndex+x] = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// downsampleRowPairToFinal downsamples 2 supersampled rows into 1 final row using box filter
|
||||
func (r *PNGRenderer) downsampleRowPairToFinal(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
|
||||
for x := 0; x < r.finalSize; x++ {
|
||||
// Sample 2x2 block from row buffer
|
||||
x0 := x * defaultSupersamplingFactor
|
||||
x1 := x0 + 1
|
||||
|
||||
// Row 0 (first supersampled row)
|
||||
idx00 := x0
|
||||
idx01 := x1
|
||||
|
||||
// Row 1 (second supersampled row)
|
||||
idx10 := supersampledWidth + x0
|
||||
idx11 := supersampledWidth + x1
|
||||
|
||||
// Sum RGBA values from 2x2 block
|
||||
var rSum, gSum, bSum, aSum uint32
|
||||
|
||||
if idx00 < len(rowBuffer) {
|
||||
c := rowBuffer[idx00]
|
||||
rSum += uint32(c.R)
|
||||
gSum += uint32(c.G)
|
||||
bSum += uint32(c.B)
|
||||
aSum += uint32(c.A)
|
||||
}
|
||||
if idx01 < len(rowBuffer) {
|
||||
c := rowBuffer[idx01]
|
||||
rSum += uint32(c.R)
|
||||
gSum += uint32(c.G)
|
||||
bSum += uint32(c.B)
|
||||
aSum += uint32(c.A)
|
||||
}
|
||||
if idx10 < len(rowBuffer) {
|
||||
c := rowBuffer[idx10]
|
||||
rSum += uint32(c.R)
|
||||
gSum += uint32(c.G)
|
||||
bSum += uint32(c.B)
|
||||
aSum += uint32(c.A)
|
||||
}
|
||||
if idx11 < len(rowBuffer) {
|
||||
c := rowBuffer[idx11]
|
||||
rSum += uint32(c.R)
|
||||
gSum += uint32(c.G)
|
||||
bSum += uint32(c.B)
|
||||
aSum += uint32(c.A)
|
||||
}
|
||||
|
||||
// Average by dividing by 4
|
||||
// #nosec G115 -- Safe: sum of 4 uint8 values (max 255*4=1020) divided by 4 always fits in uint8
|
||||
avgColor := color.RGBA{
|
||||
R: uint8(rSum / 4),
|
||||
G: uint8(gSum / 4),
|
||||
B: uint8(bSum / 4),
|
||||
A: uint8(aSum / 4),
|
||||
}
|
||||
|
||||
// Set pixel in final image
|
||||
r.finalImg.Set(x, finalY, avgColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions (reused from original implementation)
|
||||
|
||||
func (r *PNGRenderer) parseColor(colorStr string, opacity float64) color.RGBA {
|
||||
if colorStr != "" && colorStr[0] != '#' {
|
||||
colorStr = "#" + colorStr
|
||||
}
|
||||
|
||||
rgba, err := engine.ParseHexColorForRenderer(colorStr, opacity)
|
||||
if err != nil {
|
||||
return color.RGBA{0, 0, 0, uint8(opacity * 255)}
|
||||
}
|
||||
|
||||
return rgba
|
||||
}
|
||||
|
||||
func (r *PNGRenderer) isRectangle(points []engine.Point) bool {
|
||||
if len(points) != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
uniqueX := make(map[float64]struct{})
|
||||
uniqueY := make(map[float64]struct{})
|
||||
|
||||
for _, p := range points {
|
||||
uniqueX[p.X] = struct{}{}
|
||||
uniqueY[p.Y] = struct{}{}
|
||||
}
|
||||
|
||||
return len(uniqueX) == 2 && len(uniqueY) == 2
|
||||
}
|
||||
|
||||
func (r *PNGRenderer) getBoundsFloat(points []engine.Point) (float64, float64, float64, float64) {
|
||||
if len(points) == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
|
||||
minX, maxX := points[0].X, points[0].X
|
||||
minY, maxY := points[0].Y, points[0].Y
|
||||
|
||||
for _, p := range points[1:] {
|
||||
if p.X < minX {
|
||||
minX = p.X
|
||||
}
|
||||
if p.X > maxX {
|
||||
maxX = p.X
|
||||
}
|
||||
if p.Y < minY {
|
||||
minY = p.Y
|
||||
}
|
||||
@@ -183,110 +609,25 @@ func (r *PNGRenderer) fillPolygon(points []image.Point) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bounds are within image
|
||||
bounds := r.img.Bounds()
|
||||
if minY < bounds.Min.Y {
|
||||
minY = bounds.Min.Y
|
||||
}
|
||||
if maxY >= bounds.Max.Y {
|
||||
maxY = bounds.Max.Y - 1
|
||||
}
|
||||
|
||||
// For each scanline, find intersections and fill
|
||||
for y := minY; y <= maxY; y++ {
|
||||
intersections := r.getIntersections(points, y)
|
||||
if len(intersections) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort intersections and fill between pairs
|
||||
for i := 0; i < len(intersections); i += 2 {
|
||||
if i+1 < len(intersections) {
|
||||
x1, x2 := intersections[i], intersections[i+1]
|
||||
if x1 > x2 {
|
||||
x1, x2 = x2, x1
|
||||
}
|
||||
|
||||
// Clamp to image bounds
|
||||
if x1 < bounds.Min.X {
|
||||
x1 = bounds.Min.X
|
||||
}
|
||||
if x2 >= bounds.Max.X {
|
||||
x2 = bounds.Max.X - 1
|
||||
}
|
||||
|
||||
// Fill the horizontal line
|
||||
for x := x1; x <= x2; x++ {
|
||||
r.img.SetRGBA(x, y, r.currentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minX, minY, maxX, maxY
|
||||
}
|
||||
|
||||
// getIntersections finds x-coordinates where a horizontal line intersects polygon edges
|
||||
func (r *PNGRenderer) getIntersections(points []image.Point, y int) []int {
|
||||
var intersections []int
|
||||
n := len(points)
|
||||
func (r *PNGRenderer) scaleImage(src *image.RGBA, newSize int) image.Image {
|
||||
oldSize := r.finalSize
|
||||
if oldSize == newSize {
|
||||
return src
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
j := (i + 1) % n
|
||||
p1, p2 := points[i], points[j]
|
||||
scaled := image.NewRGBA(image.Rect(0, 0, newSize, newSize))
|
||||
ratio := float64(oldSize) / float64(newSize)
|
||||
|
||||
// Check if the edge crosses the scanline
|
||||
if (p1.Y <= y && p2.Y > y) || (p2.Y <= y && p1.Y > y) {
|
||||
// Calculate intersection x-coordinate
|
||||
x := p1.X + (y-p1.Y)*(p2.X-p1.X)/(p2.Y-p1.Y)
|
||||
intersections = append(intersections, x)
|
||||
for y := 0; y < newSize; y++ {
|
||||
for x := 0; x < newSize; x++ {
|
||||
srcX := int(float64(x) * ratio)
|
||||
srcY := int(float64(y) * ratio)
|
||||
scaled.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort intersections
|
||||
for i := 0; i < len(intersections)-1; i++ {
|
||||
for j := i + 1; j < len(intersections); j++ {
|
||||
if intersections[i] > intersections[j] {
|
||||
intersections[i], intersections[j] = intersections[j], intersections[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections
|
||||
}
|
||||
|
||||
// drawCircle draws a filled circle using Bresenham's algorithm
|
||||
func (r *PNGRenderer) drawCircle(centerX, centerY, radius int, invert bool) {
|
||||
bounds := r.img.Bounds()
|
||||
|
||||
// For filled circle, we'll draw it by filling horizontal lines
|
||||
for y := -radius; y <= radius; y++ {
|
||||
actualY := centerY + y
|
||||
if actualY < bounds.Min.Y || actualY >= bounds.Max.Y {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate x extent for this y
|
||||
x := int(math.Sqrt(float64(radius*radius - y*y)))
|
||||
|
||||
x1, x2 := centerX-x, centerX+x
|
||||
|
||||
// Clamp to image bounds
|
||||
if x1 < bounds.Min.X {
|
||||
x1 = bounds.Min.X
|
||||
}
|
||||
if x2 >= bounds.Max.X {
|
||||
x2 = bounds.Max.X - 1
|
||||
}
|
||||
|
||||
// Fill the horizontal line
|
||||
for x := x1; x <= x2; x++ {
|
||||
if invert {
|
||||
// For inverted circles, we need to punch a hole
|
||||
// This would typically be handled by a compositing mode
|
||||
// For now, we'll set to transparent
|
||||
r.img.SetRGBA(x, actualY, color.RGBA{0, 0, 0, 0})
|
||||
} else {
|
||||
r.img.SetRGBA(x, actualY, r.currentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user