init
This commit is contained in:
346
internal/engine/color.go
Normal file
346
internal/engine/color.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Lightness correctors for each hue segment (based on JavaScript implementation)
|
||||
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
|
||||
|
||||
// Color represents a color with both HSL and RGB representations
|
||||
type Color struct {
|
||||
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
|
||||
R, G, B uint8 // RGB values: [0,255]
|
||||
A uint8 // Alpha channel: [0,255]
|
||||
}
|
||||
|
||||
// NewColorHSL creates a new Color from HSL values
|
||||
func NewColorHSL(h, s, l float64) Color {
|
||||
r, g, b := HSLToRGB(h, s, l)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
|
||||
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
|
||||
func NewColorCorrectedHSL(h, s, l float64) Color {
|
||||
r, g, b := CorrectedHSLToRGB(h, s, l)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
|
||||
// NewColorRGB creates a new Color from RGB values and calculates HSL
|
||||
func NewColorRGB(r, g, b uint8) Color {
|
||||
h, s, l := RGBToHSL(r, g, b)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
|
||||
// NewColorRGBA creates a new Color from RGBA values and calculates HSL
|
||||
func NewColorRGBA(r, g, b, a uint8) Color {
|
||||
h, s, l := RGBToHSL(r, g, b)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: a,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the hex representation of the color
|
||||
func (c Color) String() string {
|
||||
if c.A == 255 {
|
||||
return RGBToHex(c.R, c.G, c.B)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
|
||||
}
|
||||
|
||||
// Equals compares two colors for equality
|
||||
func (c Color) Equals(other Color) bool {
|
||||
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
|
||||
}
|
||||
|
||||
// WithAlpha returns a new color with the specified alpha value
|
||||
func (c Color) WithAlpha(alpha uint8) Color {
|
||||
return Color{
|
||||
H: c.H, S: c.S, L: c.L,
|
||||
R: c.R, G: c.G, B: c.B,
|
||||
A: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
// IsGrayscale returns true if the color is grayscale (saturation near zero)
|
||||
func (c Color) IsGrayscale() bool {
|
||||
return c.S < 0.01 // Small tolerance for floating point comparison
|
||||
}
|
||||
|
||||
// Darken returns a new color with reduced lightness
|
||||
func (c Color) Darken(amount float64) Color {
|
||||
newL := clamp(c.L-amount, 0, 1)
|
||||
return NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
}
|
||||
|
||||
// Lighten returns a new color with increased lightness
|
||||
func (c Color) Lighten(amount float64) Color {
|
||||
newL := clamp(c.L+amount, 0, 1)
|
||||
return NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
}
|
||||
|
||||
// RGBToHSL converts RGB values to HSL
|
||||
// Returns H=[0,1], S=[0,1], L=[0,1]
|
||||
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
rf := float64(r) / 255.0
|
||||
gf := float64(g) / 255.0
|
||||
bf := float64(b) / 255.0
|
||||
|
||||
max := math.Max(rf, math.Max(gf, bf))
|
||||
min := math.Min(rf, math.Min(gf, bf))
|
||||
|
||||
// Calculate lightness
|
||||
l = (max + min) / 2.0
|
||||
|
||||
if max == min {
|
||||
// Achromatic (gray)
|
||||
h, s = 0, 0
|
||||
} else {
|
||||
delta := max - min
|
||||
|
||||
// Calculate saturation
|
||||
if l > 0.5 {
|
||||
s = delta / (2.0 - max - min)
|
||||
} else {
|
||||
s = delta / (max + min)
|
||||
}
|
||||
|
||||
// Calculate hue
|
||||
switch max {
|
||||
case rf:
|
||||
h = (gf-bf)/delta + (func() float64 {
|
||||
if gf < bf {
|
||||
return 6
|
||||
}
|
||||
return 0
|
||||
})()
|
||||
case gf:
|
||||
h = (bf-rf)/delta + 2
|
||||
case bf:
|
||||
h = (rf-gf)/delta + 4
|
||||
}
|
||||
h /= 6.0
|
||||
}
|
||||
|
||||
return h, s, l
|
||||
}
|
||||
|
||||
// HSLToRGB converts HSL color values to RGB.
|
||||
// h: hue in range [0, 1]
|
||||
// s: saturation in range [0, 1]
|
||||
// l: lightness in range [0, 1]
|
||||
// Returns RGB values in range [0, 255]
|
||||
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
// Clamp input values to valid ranges
|
||||
h = math.Mod(h, 1.0)
|
||||
if h < 0 {
|
||||
h += 1.0
|
||||
}
|
||||
s = clamp(s, 0, 1)
|
||||
l = clamp(l, 0, 1)
|
||||
|
||||
// Handle grayscale case (saturation = 0)
|
||||
if s == 0 {
|
||||
// All RGB components are equal for grayscale
|
||||
gray := uint8(clamp(l*255, 0, 255))
|
||||
return gray, gray, gray
|
||||
}
|
||||
|
||||
// Calculate intermediate values for HSL to RGB conversion
|
||||
var m2 float64
|
||||
if l <= 0.5 {
|
||||
m2 = l * (s + 1)
|
||||
} else {
|
||||
m2 = l + s - l*s
|
||||
}
|
||||
m1 := l*2 - m2
|
||||
|
||||
// Convert each RGB component
|
||||
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
|
||||
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
|
||||
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
|
||||
|
||||
return r, g, b
|
||||
}
|
||||
|
||||
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
|
||||
// This function adjusts the lightness based on the hue to compensate for the human eye's
|
||||
// different sensitivity to different colors.
|
||||
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
// Get the corrector for the current hue
|
||||
hueIndex := int((h*6 + 0.5)) % len(correctors)
|
||||
corrector := correctors[hueIndex]
|
||||
|
||||
// Adjust lightness relative to the corrector
|
||||
if l < 0.5 {
|
||||
l = l * corrector * 2
|
||||
} else {
|
||||
l = corrector + (l-0.5)*(1-corrector)*2
|
||||
}
|
||||
|
||||
// Clamp the corrected lightness
|
||||
l = clamp(l, 0, 1)
|
||||
|
||||
return HSLToRGB(h, s, l)
|
||||
}
|
||||
|
||||
// hueToRGB converts a hue value to an RGB component value
|
||||
// Based on the W3C CSS3 color specification
|
||||
func hueToRGB(m1, m2, h float64) float64 {
|
||||
// Normalize hue to [0, 6) range
|
||||
if h < 0 {
|
||||
h += 6
|
||||
} else if h > 6 {
|
||||
h -= 6
|
||||
}
|
||||
|
||||
// Calculate RGB component based on hue position
|
||||
if h < 1 {
|
||||
return m1 + (m2-m1)*h
|
||||
} else if h < 3 {
|
||||
return m2
|
||||
} else if h < 4 {
|
||||
return m1 + (m2-m1)*(4-h)
|
||||
} else {
|
||||
return m1
|
||||
}
|
||||
}
|
||||
|
||||
// clamp constrains a value to the specified range [min, max]
|
||||
func clamp(value, min, max float64) float64 {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RGBToHex converts RGB values to a hexadecimal color string
|
||||
func RGBToHex(r, g, b uint8) string {
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
|
||||
// ParseHexColor parses a hexadecimal color string and returns RGB values
|
||||
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
|
||||
// Returns error if the format is invalid
|
||||
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
|
||||
if len(color) == 0 || color[0] != '#' {
|
||||
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
|
||||
}
|
||||
|
||||
hex := color[1:] // Remove '#' prefix
|
||||
a = 255 // Default alpha
|
||||
|
||||
// Helper to parse a component and chain errors
|
||||
parse := func(target *uint8, hexStr string) {
|
||||
if err != nil {
|
||||
return // Don't parse if a previous component failed
|
||||
}
|
||||
*target, err = hexToByte(hexStr)
|
||||
}
|
||||
|
||||
switch len(hex) {
|
||||
case 3: // #RGB
|
||||
parse(&r, hex[0:1]+hex[0:1])
|
||||
parse(&g, hex[1:2]+hex[1:2])
|
||||
parse(&b, hex[2:3]+hex[2:3])
|
||||
case 6: // #RRGGBB
|
||||
parse(&r, hex[0:2])
|
||||
parse(&g, hex[2:4])
|
||||
parse(&b, hex[4:6])
|
||||
case 8: // #RRGGBBAA
|
||||
parse(&r, hex[0:2])
|
||||
parse(&g, hex[2:4])
|
||||
parse(&b, hex[4:6])
|
||||
parse(&a, hex[6:8])
|
||||
default:
|
||||
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Return zero values for color components on error, but keep default alpha
|
||||
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
|
||||
}
|
||||
|
||||
return r, g, b, a, nil
|
||||
}
|
||||
|
||||
// hexToByte converts a 2-character hex string to a byte value
|
||||
func hexToByte(hex string) (uint8, error) {
|
||||
if len(hex) != 2 {
|
||||
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
|
||||
}
|
||||
|
||||
n, err := strconv.ParseUint(hex, 16, 8)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
|
||||
}
|
||||
return uint8(n), nil
|
||||
}
|
||||
|
||||
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
|
||||
func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Color {
|
||||
// Restrict hue according to configuration
|
||||
restrictedHue := config.RestrictHue(hue)
|
||||
|
||||
// Get lightness from configuration range
|
||||
lightness := config.ColorLightness.GetLightness(lightnessValue)
|
||||
|
||||
// Use corrected HSL to RGB conversion
|
||||
return NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, lightness)
|
||||
}
|
||||
|
||||
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
|
||||
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
|
||||
// For grayscale, hue doesn't matter, but we'll use 0
|
||||
hue := 0.0
|
||||
|
||||
// Get lightness from grayscale configuration range
|
||||
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
|
||||
|
||||
// Use grayscale saturation (typically 0)
|
||||
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
|
||||
}
|
||||
|
||||
// GenerateColorTheme generates a set of color candidates based on the JavaScript colorTheme function
|
||||
// This matches the JavaScript implementation that creates 5 colors:
|
||||
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
|
||||
func GenerateColorTheme(hue float64, config ColorConfig) []Color {
|
||||
// Restrict hue according to configuration
|
||||
restrictedHue := config.RestrictHue(hue)
|
||||
|
||||
return []Color{
|
||||
// Dark gray (grayscale with lightness 0)
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
|
||||
|
||||
// Mid color (normal color with lightness 0.5)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
|
||||
|
||||
// Light gray (grayscale with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
|
||||
|
||||
// Light color (normal color with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
|
||||
|
||||
// Dark color (normal color with lightness 0)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
|
||||
}
|
||||
}
|
||||
35
internal/engine/color_bench_test.go
Normal file
35
internal/engine/color_bench_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var benchmarkCases = []struct {
|
||||
h, s, l float64
|
||||
}{
|
||||
{0.0, 0.5, 0.5}, // Red
|
||||
{0.33, 0.5, 0.5}, // Green
|
||||
{0.66, 0.5, 0.5}, // Blue
|
||||
{0.5, 1.0, 0.3}, // Cyan dark
|
||||
{0.8, 0.8, 0.7}, // Purple light
|
||||
}
|
||||
|
||||
func BenchmarkCorrectedHSLToRGB(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
tc := benchmarkCases[i%len(benchmarkCases)]
|
||||
CorrectedHSLToRGB(tc.h, tc.s, tc.l)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNewColorCorrectedHSL(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
tc := benchmarkCases[i%len(benchmarkCases)]
|
||||
NewColorCorrectedHSL(tc.h, tc.s, tc.l)
|
||||
}
|
||||
}
|
||||
663
internal/engine/color_test.go
Normal file
663
internal/engine/color_test.go
Normal file
@@ -0,0 +1,663 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHSLToRGB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
h, s, l float64
|
||||
r, g, b uint8
|
||||
}{
|
||||
{
|
||||
name: "pure red",
|
||||
h: 0.0, s: 1.0, l: 0.5,
|
||||
r: 255, g: 0, b: 0,
|
||||
},
|
||||
{
|
||||
name: "pure green",
|
||||
h: 1.0/3.0, s: 1.0, l: 0.5,
|
||||
r: 0, g: 255, b: 0,
|
||||
},
|
||||
{
|
||||
name: "pure blue",
|
||||
h: 2.0/3.0, s: 1.0, l: 0.5,
|
||||
r: 0, g: 0, b: 255,
|
||||
},
|
||||
{
|
||||
name: "white",
|
||||
h: 0.0, s: 0.0, l: 1.0,
|
||||
r: 255, g: 255, b: 255,
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
h: 0.0, s: 0.0, l: 0.0,
|
||||
r: 0, g: 0, b: 0,
|
||||
},
|
||||
{
|
||||
name: "gray",
|
||||
h: 0.0, s: 0.0, l: 0.5,
|
||||
r: 127, g: 127, b: 127,
|
||||
},
|
||||
{
|
||||
name: "dark red",
|
||||
h: 0.0, s: 1.0, l: 0.25,
|
||||
r: 127, g: 0, b: 0,
|
||||
},
|
||||
{
|
||||
name: "light blue",
|
||||
h: 2.0/3.0, s: 1.0, l: 0.75,
|
||||
r: 127, g: 127, b: 255,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, g, b := HSLToRGB(tt.h, tt.s, tt.l)
|
||||
|
||||
// Allow small tolerance due to floating point arithmetic
|
||||
tolerance := uint8(2)
|
||||
if abs(int(r), int(tt.r)) > int(tolerance) ||
|
||||
abs(int(g), int(tt.g)) > int(tolerance) ||
|
||||
abs(int(b), int(tt.b)) > int(tolerance) {
|
||||
t.Errorf("HSLToRGB(%f, %f, %f) = (%d, %d, %d), want (%d, %d, %d)",
|
||||
tt.h, tt.s, tt.l, r, g, b, tt.r, tt.g, tt.b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectedHSLToRGB(t *testing.T) {
|
||||
// Test that corrected HSL produces valid RGB values
|
||||
testCases := []struct {
|
||||
name string
|
||||
h, s, l float64
|
||||
}{
|
||||
{"Red", 0.0, 1.0, 0.5},
|
||||
{"Green", 0.33, 1.0, 0.5},
|
||||
{"Blue", 0.67, 1.0, 0.5},
|
||||
{"Gray", 0.0, 0.0, 0.5},
|
||||
{"DarkCyan", 0.5, 0.7, 0.3},
|
||||
{"LightMagenta", 0.8, 0.8, 0.8},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, g, b := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
|
||||
|
||||
// Verify RGB values are in valid range
|
||||
if r > 255 || g > 255 || b > 255 {
|
||||
t.Errorf("CorrectedHSLToRGB(%f, %f, %f) = (%d, %d, %d), RGB values should be <= 255",
|
||||
tc.h, tc.s, tc.l, r, g, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRGBToHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
r, g, b uint8
|
||||
expected string
|
||||
}{
|
||||
{"black", 0, 0, 0, "#000000"},
|
||||
{"white", 255, 255, 255, "#ffffff"},
|
||||
{"red", 255, 0, 0, "#ff0000"},
|
||||
{"green", 0, 255, 0, "#00ff00"},
|
||||
{"blue", 0, 0, 255, "#0000ff"},
|
||||
{"gray", 128, 128, 128, "#808080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := RGBToHex(tt.r, tt.g, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("RGBToHex(%d, %d, %d) = %s, want %s", tt.r, tt.g, tt.b, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToByte(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint8
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid hex 00",
|
||||
input: "00",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "valid hex ff",
|
||||
input: "ff",
|
||||
expected: 255,
|
||||
},
|
||||
{
|
||||
name: "valid hex a5",
|
||||
input: "a5",
|
||||
expected: 165,
|
||||
},
|
||||
{
|
||||
name: "valid hex A5 uppercase",
|
||||
input: "A5",
|
||||
expected: 165,
|
||||
},
|
||||
{
|
||||
name: "invalid length - too short",
|
||||
input: "f",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid length - too long",
|
||||
input: "fff",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid character x",
|
||||
input: "fx",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid character z",
|
||||
input: "zz",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := hexToByte(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("hexToByte(%s) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("hexToByte(%s) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("hexToByte(%s) = %d, want %d", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectError bool
|
||||
r, g, b, a uint8
|
||||
}{
|
||||
{
|
||||
name: "3-char hex",
|
||||
input: "#f0a",
|
||||
r: 255, g: 0, b: 170, a: 255,
|
||||
},
|
||||
{
|
||||
name: "6-char hex",
|
||||
input: "#ff00aa",
|
||||
r: 255, g: 0, b: 170, a: 255,
|
||||
},
|
||||
{
|
||||
name: "8-char hex with alpha",
|
||||
input: "#ff00aa80",
|
||||
r: 255, g: 0, b: 170, a: 128,
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
input: "#000",
|
||||
r: 0, g: 0, b: 0, a: 255,
|
||||
},
|
||||
{
|
||||
name: "white",
|
||||
input: "#fff",
|
||||
r: 255, g: 255, b: 255, a: 255,
|
||||
},
|
||||
{
|
||||
name: "invalid format - no hash",
|
||||
input: "ff0000",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - too short",
|
||||
input: "#f",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - too long",
|
||||
input: "#ff00aa12345",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hex character in 3-char",
|
||||
input: "#fxz",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hex character in 6-char",
|
||||
input: "#ff00xz",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hex character in 8-char",
|
||||
input: "#ff00aaxz",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, g, b, a, err := ParseHexColor(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseHexColor(%s) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseHexColor(%s) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if r != tt.r || g != tt.g || b != tt.b || a != tt.a {
|
||||
t.Errorf("ParseHexColor(%s) = (%d, %d, %d, %d), want (%d, %d, %d, %d)",
|
||||
tt.input, r, g, b, a, tt.r, tt.g, tt.b, tt.a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
value, min, max, expected float64
|
||||
}{
|
||||
{0.5, 0.0, 1.0, 0.5}, // within range
|
||||
{-0.5, 0.0, 1.0, 0.0}, // below min
|
||||
{1.5, 0.0, 1.0, 1.0}, // above max
|
||||
{0.0, 0.0, 1.0, 0.0}, // at min
|
||||
{1.0, 0.0, 1.0, 1.0}, // at max
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := clamp(tt.value, tt.min, tt.max)
|
||||
if result != tt.expected {
|
||||
t.Errorf("clamp(%f, %f, %f) = %f, want %f", tt.value, tt.min, tt.max, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewColorHSL(t *testing.T) {
|
||||
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
|
||||
|
||||
if color.H != 0.0 || color.S != 1.0 || color.L != 0.5 {
|
||||
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) HSL = (%f, %f, %f), want (0.0, 1.0, 0.5)",
|
||||
color.H, color.S, color.L)
|
||||
}
|
||||
|
||||
if color.R != 255 || color.G != 0 || color.B != 0 {
|
||||
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
|
||||
color.R, color.G, color.B)
|
||||
}
|
||||
|
||||
if color.A != 255 {
|
||||
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) A = %d, want 255", color.A)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewColorRGB(t *testing.T) {
|
||||
color := NewColorRGB(255, 0, 0) // Pure red
|
||||
|
||||
if color.R != 255 || color.G != 0 || color.B != 0 {
|
||||
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
|
||||
color.R, color.G, color.B)
|
||||
}
|
||||
|
||||
// HSL values should be approximately (0, 1, 0.5) for pure red
|
||||
tolerance := 0.01
|
||||
if math.Abs(color.H-0.0) > tolerance || math.Abs(color.S-1.0) > tolerance || math.Abs(color.L-0.5) > tolerance {
|
||||
t.Errorf("NewColorRGB(255, 0, 0) HSL = (%f, %f, %f), want approximately (0.0, 1.0, 0.5)",
|
||||
color.H, color.S, color.L)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
color Color
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "red without alpha",
|
||||
color: NewColorRGB(255, 0, 0),
|
||||
expected: "#ff0000",
|
||||
},
|
||||
{
|
||||
name: "blue with alpha",
|
||||
color: NewColorRGBA(0, 0, 255, 128),
|
||||
expected: "#0000ff80",
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
color: NewColorRGB(0, 0, 0),
|
||||
expected: "#000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.color.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Color.String() = %s, want %s", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorEquals(t *testing.T) {
|
||||
color1 := NewColorRGB(255, 0, 0)
|
||||
color2 := NewColorRGB(255, 0, 0)
|
||||
color3 := NewColorRGB(0, 255, 0)
|
||||
color4 := NewColorRGBA(255, 0, 0, 128)
|
||||
|
||||
if !color1.Equals(color2) {
|
||||
t.Error("Expected equal colors to be equal")
|
||||
}
|
||||
|
||||
if color1.Equals(color3) {
|
||||
t.Error("Expected different colors to not be equal")
|
||||
}
|
||||
|
||||
if color1.Equals(color4) {
|
||||
t.Error("Expected colors with different alpha to not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorWithAlpha(t *testing.T) {
|
||||
color := NewColorRGB(255, 0, 0)
|
||||
newColor := color.WithAlpha(128)
|
||||
|
||||
if newColor.A != 128 {
|
||||
t.Errorf("WithAlpha(128) A = %d, want 128", newColor.A)
|
||||
}
|
||||
|
||||
// RGB and HSL should remain the same
|
||||
if newColor.R != color.R || newColor.G != color.G || newColor.B != color.B {
|
||||
t.Error("WithAlpha should not change RGB values")
|
||||
}
|
||||
|
||||
if newColor.H != color.H || newColor.S != color.S || newColor.L != color.L {
|
||||
t.Error("WithAlpha should not change HSL values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorIsGrayscale(t *testing.T) {
|
||||
grayColor := NewColorRGB(128, 128, 128)
|
||||
redColor := NewColorRGB(255, 0, 0)
|
||||
|
||||
if !grayColor.IsGrayscale() {
|
||||
t.Error("Expected gray color to be identified as grayscale")
|
||||
}
|
||||
|
||||
if redColor.IsGrayscale() {
|
||||
t.Error("Expected red color to not be identified as grayscale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorDarkenLighten(t *testing.T) {
|
||||
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
|
||||
|
||||
darker := color.Darken(0.2)
|
||||
if darker.L >= color.L {
|
||||
t.Error("Darken should reduce lightness")
|
||||
}
|
||||
|
||||
lighter := color.Lighten(0.2)
|
||||
if lighter.L <= color.L {
|
||||
t.Error("Lighten should increase lightness")
|
||||
}
|
||||
|
||||
// Test clamping
|
||||
veryDark := color.Darken(1.0)
|
||||
if veryDark.L != 0.0 {
|
||||
t.Errorf("Darken with large amount should clamp to 0, got %f", veryDark.L)
|
||||
}
|
||||
|
||||
veryLight := color.Lighten(1.0)
|
||||
if veryLight.L != 1.0 {
|
||||
t.Errorf("Lighten with large amount should clamp to 1, got %f", veryLight.L)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRGBToHSL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
r, g, b uint8
|
||||
h, s, l float64
|
||||
}{
|
||||
{
|
||||
name: "red",
|
||||
r: 255, g: 0, b: 0,
|
||||
h: 0.0, s: 1.0, l: 0.5,
|
||||
},
|
||||
{
|
||||
name: "green",
|
||||
r: 0, g: 255, b: 0,
|
||||
h: 1.0/3.0, s: 1.0, l: 0.5,
|
||||
},
|
||||
{
|
||||
name: "blue",
|
||||
r: 0, g: 0, b: 255,
|
||||
h: 2.0/3.0, s: 1.0, l: 0.5,
|
||||
},
|
||||
{
|
||||
name: "white",
|
||||
r: 255, g: 255, b: 255,
|
||||
h: 0.0, s: 0.0, l: 1.0,
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
r: 0, g: 0, b: 0,
|
||||
h: 0.0, s: 0.0, l: 0.0,
|
||||
},
|
||||
{
|
||||
name: "gray",
|
||||
r: 128, g: 128, b: 128,
|
||||
h: 0.0, s: 0.0, l: 0.502, // approximately 0.5
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, s, l := RGBToHSL(tt.r, tt.g, tt.b)
|
||||
|
||||
tolerance := 0.01
|
||||
if math.Abs(h-tt.h) > tolerance || math.Abs(s-tt.s) > tolerance || math.Abs(l-tt.l) > tolerance {
|
||||
t.Errorf("RGBToHSL(%d, %d, %d) = (%f, %f, %f), want approximately (%f, %f, %f)",
|
||||
tt.r, tt.g, tt.b, h, s, l, tt.h, tt.s, tt.l)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateColor(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
|
||||
// Test color generation with mid-range lightness
|
||||
color := GenerateColor(0.0, config, 0.5) // Red hue, mid lightness
|
||||
|
||||
// Should be approximately red with default saturation (0.5) and mid lightness (0.6)
|
||||
expectedLightness := config.ColorLightness.GetLightness(0.5) // Should be 0.6
|
||||
tolerance := 0.01
|
||||
|
||||
if math.Abs(color.H-0.0) > tolerance {
|
||||
t.Errorf("GenerateColor hue = %f, want approximately 0.0", color.H)
|
||||
}
|
||||
|
||||
if math.Abs(color.S-config.ColorSaturation) > tolerance {
|
||||
t.Errorf("GenerateColor saturation = %f, want %f", color.S, config.ColorSaturation)
|
||||
}
|
||||
|
||||
if math.Abs(color.L-expectedLightness) > tolerance {
|
||||
t.Errorf("GenerateColor lightness = %f, want approximately %f", color.L, expectedLightness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGrayscale(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
|
||||
// Test grayscale generation
|
||||
color := GenerateGrayscale(config, 0.5)
|
||||
|
||||
// Should be grayscale (saturation 0) with mid lightness
|
||||
expectedLightness := config.GrayscaleLightness.GetLightness(0.5) // Should be 0.6
|
||||
tolerance := 0.01
|
||||
|
||||
if math.Abs(color.S-config.GrayscaleSaturation) > tolerance {
|
||||
t.Errorf("GenerateGrayscale saturation = %f, want %f", color.S, config.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
if math.Abs(color.L-expectedLightness) > tolerance {
|
||||
t.Errorf("GenerateGrayscale lightness = %f, want approximately %f", color.L, expectedLightness)
|
||||
}
|
||||
|
||||
if !color.IsGrayscale() {
|
||||
t.Error("GenerateGrayscale should produce a grayscale color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateColorTheme(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
hue := 0.25 // Green-ish hue
|
||||
|
||||
theme := GenerateColorTheme(hue, config)
|
||||
|
||||
// Should have exactly 5 colors
|
||||
if len(theme) != 5 {
|
||||
t.Errorf("GenerateColorTheme returned %d colors, want 5", len(theme))
|
||||
}
|
||||
|
||||
// Test color indices according to JavaScript implementation:
|
||||
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
|
||||
|
||||
// Index 0: Dark gray (grayscale with lightness 0)
|
||||
darkGray := theme[0]
|
||||
if !darkGray.IsGrayscale() {
|
||||
t.Error("Theme color 0 should be grayscale (dark gray)")
|
||||
}
|
||||
expectedLightness := config.GrayscaleLightness.GetLightness(0)
|
||||
if math.Abs(darkGray.L-expectedLightness) > 0.01 {
|
||||
t.Errorf("Dark gray lightness = %f, want %f", darkGray.L, expectedLightness)
|
||||
}
|
||||
|
||||
// Index 1: Mid color (normal color with lightness 0.5)
|
||||
midColor := theme[1]
|
||||
if midColor.IsGrayscale() {
|
||||
t.Error("Theme color 1 should not be grayscale (mid color)")
|
||||
}
|
||||
expectedLightness = config.ColorLightness.GetLightness(0.5)
|
||||
if math.Abs(midColor.L-expectedLightness) > 0.01 {
|
||||
t.Errorf("Mid color lightness = %f, want %f", midColor.L, expectedLightness)
|
||||
}
|
||||
|
||||
// Index 2: Light gray (grayscale with lightness 1)
|
||||
lightGray := theme[2]
|
||||
if !lightGray.IsGrayscale() {
|
||||
t.Error("Theme color 2 should be grayscale (light gray)")
|
||||
}
|
||||
expectedLightness = config.GrayscaleLightness.GetLightness(1)
|
||||
if math.Abs(lightGray.L-expectedLightness) > 0.01 {
|
||||
t.Errorf("Light gray lightness = %f, want %f", lightGray.L, expectedLightness)
|
||||
}
|
||||
|
||||
// Index 3: Light color (normal color with lightness 1)
|
||||
lightColor := theme[3]
|
||||
if lightColor.IsGrayscale() {
|
||||
t.Error("Theme color 3 should not be grayscale (light color)")
|
||||
}
|
||||
expectedLightness = config.ColorLightness.GetLightness(1)
|
||||
if math.Abs(lightColor.L-expectedLightness) > 0.01 {
|
||||
t.Errorf("Light color lightness = %f, want %f", lightColor.L, expectedLightness)
|
||||
}
|
||||
|
||||
// Index 4: Dark color (normal color with lightness 0)
|
||||
darkColor := theme[4]
|
||||
if darkColor.IsGrayscale() {
|
||||
t.Error("Theme color 4 should not be grayscale (dark color)")
|
||||
}
|
||||
expectedLightness = config.ColorLightness.GetLightness(0)
|
||||
if math.Abs(darkColor.L-expectedLightness) > 0.01 {
|
||||
t.Errorf("Dark color lightness = %f, want %f", darkColor.L, expectedLightness)
|
||||
}
|
||||
|
||||
// All colors should have the same hue (or close to it for grayscale)
|
||||
for i, color := range theme {
|
||||
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
|
||||
if math.Abs(color.H-hue) > 0.01 {
|
||||
t.Errorf("Theme color %d hue = %f, want approximately %f", i, color.H, hue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
|
||||
// Test with hue restriction
|
||||
config := NewColorConfigBuilder().
|
||||
WithHues(180). // Only allow cyan (180 degrees = 0.5 turns)
|
||||
Build()
|
||||
|
||||
theme := GenerateColorTheme(0.25, config) // Request green, should get cyan
|
||||
|
||||
for i, color := range theme {
|
||||
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
|
||||
if math.Abs(color.H-0.5) > 0.01 {
|
||||
t.Errorf("Theme color %d hue = %f, want approximately 0.5 (restricted)", i, color.H)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateColorWithConfiguration(t *testing.T) {
|
||||
// Test with custom configuration
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(0.8).
|
||||
WithColorLightness(0.2, 0.6).
|
||||
Build()
|
||||
|
||||
color := GenerateColor(0.33, config, 1.0) // Green hue, max lightness
|
||||
|
||||
tolerance := 0.01
|
||||
if math.Abs(color.S-0.8) > tolerance {
|
||||
t.Errorf("Custom config saturation = %f, want 0.8", color.S)
|
||||
}
|
||||
|
||||
expectedLightness := config.ColorLightness.GetLightness(1.0) // Should be 0.6
|
||||
if math.Abs(color.L-expectedLightness) > tolerance {
|
||||
t.Errorf("Custom config lightness = %f, want %f", color.L, expectedLightness)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for absolute difference
|
||||
func abs(a, b int) int {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return b - a
|
||||
}
|
||||
175
internal/engine/config.go
Normal file
175
internal/engine/config.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package engine
|
||||
|
||||
import "math"
|
||||
|
||||
// ColorConfig represents the configuration for color generation
|
||||
type ColorConfig struct {
|
||||
// Saturation settings
|
||||
ColorSaturation float64 // Saturation for normal colors [0, 1]
|
||||
GrayscaleSaturation float64 // Saturation for grayscale colors [0, 1]
|
||||
|
||||
// Lightness ranges
|
||||
ColorLightness LightnessRange // Lightness range for normal colors
|
||||
GrayscaleLightness LightnessRange // Lightness range for grayscale colors
|
||||
|
||||
// Hue restrictions
|
||||
Hues []float64 // Allowed hues in degrees [0, 360] or range [0, 1]. Empty means no restriction
|
||||
|
||||
// Background color
|
||||
BackColor *Color // Background color (nil for transparent)
|
||||
|
||||
// Icon padding
|
||||
IconPadding float64 // Padding as percentage of icon size [0, 1]
|
||||
}
|
||||
|
||||
// LightnessRange represents a range of lightness values
|
||||
type LightnessRange struct {
|
||||
Min float64 // Minimum lightness [0, 1]
|
||||
Max float64 // Maximum lightness [0, 1]
|
||||
}
|
||||
|
||||
// GetLightness returns a lightness value for the given position in range [0, 1]
|
||||
// where 0 returns Min and 1 returns Max
|
||||
func (lr LightnessRange) GetLightness(value float64) float64 {
|
||||
// Clamp value to [0, 1] range
|
||||
value = clamp(value, 0, 1)
|
||||
|
||||
// Linear interpolation between min and max
|
||||
result := lr.Min + value*(lr.Max-lr.Min)
|
||||
|
||||
// Clamp result to valid lightness range
|
||||
return clamp(result, 0, 1)
|
||||
}
|
||||
|
||||
// DefaultColorConfig returns the default configuration matching the JavaScript implementation
|
||||
func DefaultColorConfig() ColorConfig {
|
||||
return ColorConfig{
|
||||
ColorSaturation: 0.5,
|
||||
GrayscaleSaturation: 0.0,
|
||||
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
|
||||
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
|
||||
Hues: nil, // No hue restriction
|
||||
BackColor: nil, // Transparent background
|
||||
IconPadding: 0.08,
|
||||
}
|
||||
}
|
||||
|
||||
// RestrictHue applies hue restrictions to the given hue value
|
||||
// Returns the restricted hue in range [0, 1]
|
||||
func (c ColorConfig) RestrictHue(originalHue float64) float64 {
|
||||
// Normalize hue to [0, 1) range
|
||||
hue := math.Mod(originalHue, 1.0)
|
||||
if hue < 0 {
|
||||
hue += 1.0
|
||||
}
|
||||
|
||||
// If no hue restrictions, return original
|
||||
if len(c.Hues) == 0 {
|
||||
return hue
|
||||
}
|
||||
|
||||
// Find the closest allowed hue
|
||||
// originalHue is in range [0, 1], multiply by 0.999 to get range [0, 1)
|
||||
// then truncate to get index
|
||||
index := int((0.999 * hue * float64(len(c.Hues))))
|
||||
if index >= len(c.Hues) {
|
||||
index = len(c.Hues) - 1
|
||||
}
|
||||
|
||||
restrictedHue := c.Hues[index]
|
||||
|
||||
// Convert from degrees to turns in range [0, 1)
|
||||
// Handle any turn - e.g. 746° is valid
|
||||
result := math.Mod(restrictedHue/360.0, 1.0)
|
||||
if result < 0 {
|
||||
result += 1.0
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateConfig validates and corrects a ColorConfig to ensure all values are within valid ranges
|
||||
func (c *ColorConfig) Validate() {
|
||||
// Clamp saturation values
|
||||
c.ColorSaturation = clamp(c.ColorSaturation, 0, 1)
|
||||
c.GrayscaleSaturation = clamp(c.GrayscaleSaturation, 0, 1)
|
||||
|
||||
// Validate lightness ranges
|
||||
c.ColorLightness.Min = clamp(c.ColorLightness.Min, 0, 1)
|
||||
c.ColorLightness.Max = clamp(c.ColorLightness.Max, 0, 1)
|
||||
if c.ColorLightness.Min > c.ColorLightness.Max {
|
||||
c.ColorLightness.Min, c.ColorLightness.Max = c.ColorLightness.Max, c.ColorLightness.Min
|
||||
}
|
||||
|
||||
c.GrayscaleLightness.Min = clamp(c.GrayscaleLightness.Min, 0, 1)
|
||||
c.GrayscaleLightness.Max = clamp(c.GrayscaleLightness.Max, 0, 1)
|
||||
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
|
||||
c.GrayscaleLightness.Min, c.GrayscaleLightness.Max = c.GrayscaleLightness.Max, c.GrayscaleLightness.Min
|
||||
}
|
||||
|
||||
// Clamp icon padding
|
||||
c.IconPadding = clamp(c.IconPadding, 0, 1)
|
||||
|
||||
// Validate hues (no need to clamp as RestrictHue handles normalization)
|
||||
}
|
||||
|
||||
// ColorConfigBuilder provides a fluent interface for building ColorConfig
|
||||
type ColorConfigBuilder struct {
|
||||
config ColorConfig
|
||||
}
|
||||
|
||||
// NewColorConfigBuilder creates a new builder with default values
|
||||
func NewColorConfigBuilder() *ColorConfigBuilder {
|
||||
return &ColorConfigBuilder{
|
||||
config: DefaultColorConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorSaturation sets the color saturation
|
||||
func (b *ColorConfigBuilder) WithColorSaturation(saturation float64) *ColorConfigBuilder {
|
||||
b.config.ColorSaturation = saturation
|
||||
return b
|
||||
}
|
||||
|
||||
// WithGrayscaleSaturation sets the grayscale saturation
|
||||
func (b *ColorConfigBuilder) WithGrayscaleSaturation(saturation float64) *ColorConfigBuilder {
|
||||
b.config.GrayscaleSaturation = saturation
|
||||
return b
|
||||
}
|
||||
|
||||
// WithColorLightness sets the color lightness range
|
||||
func (b *ColorConfigBuilder) WithColorLightness(min, max float64) *ColorConfigBuilder {
|
||||
b.config.ColorLightness = LightnessRange{Min: min, Max: max}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithGrayscaleLightness sets the grayscale lightness range
|
||||
func (b *ColorConfigBuilder) WithGrayscaleLightness(min, max float64) *ColorConfigBuilder {
|
||||
b.config.GrayscaleLightness = LightnessRange{Min: min, Max: max}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithHues sets the allowed hues in degrees
|
||||
func (b *ColorConfigBuilder) WithHues(hues ...float64) *ColorConfigBuilder {
|
||||
b.config.Hues = make([]float64, len(hues))
|
||||
copy(b.config.Hues, hues)
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBackColor sets the background color
|
||||
func (b *ColorConfigBuilder) WithBackColor(color Color) *ColorConfigBuilder {
|
||||
b.config.BackColor = &color
|
||||
return b
|
||||
}
|
||||
|
||||
// WithIconPadding sets the icon padding
|
||||
func (b *ColorConfigBuilder) WithIconPadding(padding float64) *ColorConfigBuilder {
|
||||
b.config.IconPadding = padding
|
||||
return b
|
||||
}
|
||||
|
||||
// Build returns the configured ColorConfig after validation
|
||||
func (b *ColorConfigBuilder) Build() ColorConfig {
|
||||
b.config.Validate()
|
||||
return b.config
|
||||
}
|
||||
218
internal/engine/config_test.go
Normal file
218
internal/engine/config_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultColorConfig(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
|
||||
// Test default values match JavaScript implementation
|
||||
if config.ColorSaturation != 0.5 {
|
||||
t.Errorf("ColorSaturation = %f, want 0.5", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 0.0 {
|
||||
t.Errorf("GrayscaleSaturation = %f, want 0.0", config.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
if config.ColorLightness.Min != 0.4 || config.ColorLightness.Max != 0.8 {
|
||||
t.Errorf("ColorLightness = {%f, %f}, want {0.4, 0.8}",
|
||||
config.ColorLightness.Min, config.ColorLightness.Max)
|
||||
}
|
||||
|
||||
if config.GrayscaleLightness.Min != 0.3 || config.GrayscaleLightness.Max != 0.9 {
|
||||
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.3, 0.9}",
|
||||
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
|
||||
}
|
||||
|
||||
if len(config.Hues) != 0 {
|
||||
t.Errorf("Hues should be empty by default, got %v", config.Hues)
|
||||
}
|
||||
|
||||
if config.BackColor != nil {
|
||||
t.Error("BackColor should be nil by default")
|
||||
}
|
||||
|
||||
if config.IconPadding != 0.08 {
|
||||
t.Errorf("IconPadding = %f, want 0.08", config.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightnessRangeGetLightness(t *testing.T) {
|
||||
lr := LightnessRange{Min: 0.3, Max: 0.9}
|
||||
|
||||
tests := []struct {
|
||||
value float64
|
||||
expected float64
|
||||
}{
|
||||
{0.0, 0.3}, // Min value
|
||||
{1.0, 0.9}, // Max value
|
||||
{0.5, 0.6}, // Middle value: 0.3 + 0.5 * (0.9 - 0.3) = 0.6
|
||||
{-0.5, 0.3}, // Below range, should clamp to min
|
||||
{1.5, 0.9}, // Above range, should clamp to max
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := lr.GetLightness(tt.value)
|
||||
if math.Abs(result-tt.expected) > 0.001 {
|
||||
t.Errorf("GetLightness(%f) = %f, want %f", tt.value, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRestrictHue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hues []float64
|
||||
originalHue float64
|
||||
expectedHue float64
|
||||
}{
|
||||
{
|
||||
name: "no restriction",
|
||||
hues: nil,
|
||||
originalHue: 0.25,
|
||||
expectedHue: 0.25,
|
||||
},
|
||||
{
|
||||
name: "empty restriction",
|
||||
hues: []float64{},
|
||||
originalHue: 0.25,
|
||||
expectedHue: 0.25,
|
||||
},
|
||||
{
|
||||
name: "single hue restriction",
|
||||
hues: []float64{180}, // 180 degrees = 0.5 turns
|
||||
originalHue: 0.25,
|
||||
expectedHue: 0.5,
|
||||
},
|
||||
{
|
||||
name: "multiple hue restriction",
|
||||
hues: []float64{0, 120, 240}, // Red, Green, Blue
|
||||
originalHue: 0.1, // Should map to first hue (0 degrees)
|
||||
expectedHue: 0.0,
|
||||
},
|
||||
{
|
||||
name: "hue normalization - negative",
|
||||
hues: []float64{90}, // 90 degrees = 0.25 turns
|
||||
originalHue: -0.5,
|
||||
expectedHue: 0.25,
|
||||
},
|
||||
{
|
||||
name: "hue normalization - over 1",
|
||||
hues: []float64{270}, // 270 degrees = 0.75 turns
|
||||
originalHue: 1.5,
|
||||
expectedHue: 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := ColorConfig{Hues: tt.hues}
|
||||
result := config.RestrictHue(tt.originalHue)
|
||||
|
||||
if math.Abs(result-tt.expectedHue) > 0.001 {
|
||||
t.Errorf("RestrictHue(%f) = %f, want %f", tt.originalHue, result, tt.expectedHue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
// Test that validation corrects invalid values
|
||||
config := ColorConfig{
|
||||
ColorSaturation: -0.5, // Invalid: below 0
|
||||
GrayscaleSaturation: 1.5, // Invalid: above 1
|
||||
ColorLightness: LightnessRange{Min: 0.8, Max: 0.2}, // Invalid: min > max
|
||||
GrayscaleLightness: LightnessRange{Min: -0.1, Max: 1.1}, // Invalid: out of range
|
||||
IconPadding: 2.0, // Invalid: above 1
|
||||
}
|
||||
|
||||
config.Validate()
|
||||
|
||||
if config.ColorSaturation != 0.0 {
|
||||
t.Errorf("ColorSaturation after validation = %f, want 0.0", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 1.0 {
|
||||
t.Errorf("GrayscaleSaturation after validation = %f, want 1.0", config.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
// Min and max should be swapped
|
||||
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
|
||||
t.Errorf("ColorLightness after validation = {%f, %f}, want {0.2, 0.8}",
|
||||
config.ColorLightness.Min, config.ColorLightness.Max)
|
||||
}
|
||||
|
||||
// Values should be clamped
|
||||
if config.GrayscaleLightness.Min != 0.0 || config.GrayscaleLightness.Max != 1.0 {
|
||||
t.Errorf("GrayscaleLightness after validation = {%f, %f}, want {0.0, 1.0}",
|
||||
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
|
||||
}
|
||||
|
||||
if config.IconPadding != 1.0 {
|
||||
t.Errorf("IconPadding after validation = %f, want 1.0", config.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorConfigBuilder(t *testing.T) {
|
||||
redColor := NewColorRGB(255, 0, 0)
|
||||
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(0.7).
|
||||
WithGrayscaleSaturation(0.1).
|
||||
WithColorLightness(0.2, 0.8).
|
||||
WithGrayscaleLightness(0.1, 0.9).
|
||||
WithHues(0, 120, 240).
|
||||
WithBackColor(redColor).
|
||||
WithIconPadding(0.1).
|
||||
Build()
|
||||
|
||||
if config.ColorSaturation != 0.7 {
|
||||
t.Errorf("ColorSaturation = %f, want 0.7", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 0.1 {
|
||||
t.Errorf("GrayscaleSaturation = %f, want 0.1", config.GrayscaleSaturation)
|
||||
}
|
||||
|
||||
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
|
||||
t.Errorf("ColorLightness = {%f, %f}, want {0.2, 0.8}",
|
||||
config.ColorLightness.Min, config.ColorLightness.Max)
|
||||
}
|
||||
|
||||
if config.GrayscaleLightness.Min != 0.1 || config.GrayscaleLightness.Max != 0.9 {
|
||||
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.1, 0.9}",
|
||||
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
|
||||
}
|
||||
|
||||
if len(config.Hues) != 3 || config.Hues[0] != 0 || config.Hues[1] != 120 || config.Hues[2] != 240 {
|
||||
t.Errorf("Hues = %v, want [0, 120, 240]", config.Hues)
|
||||
}
|
||||
|
||||
if config.BackColor == nil || !config.BackColor.Equals(redColor) {
|
||||
t.Error("BackColor should be set to red")
|
||||
}
|
||||
|
||||
if config.IconPadding != 0.1 {
|
||||
t.Errorf("IconPadding = %f, want 0.1", config.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorConfigBuilderValidation(t *testing.T) {
|
||||
// Test that builder validates configuration
|
||||
config := NewColorConfigBuilder().
|
||||
WithColorSaturation(-0.5). // Invalid
|
||||
WithGrayscaleSaturation(1.5). // Invalid
|
||||
Build()
|
||||
|
||||
// Should be corrected by validation
|
||||
if config.ColorSaturation != 0.0 {
|
||||
t.Errorf("ColorSaturation = %f, want 0.0 (corrected)", config.ColorSaturation)
|
||||
}
|
||||
|
||||
if config.GrayscaleSaturation != 1.0 {
|
||||
t.Errorf("GrayscaleSaturation = %f, want 1.0 (corrected)", config.GrayscaleSaturation)
|
||||
}
|
||||
}
|
||||
353
internal/engine/generator.go
Normal file
353
internal/engine/generator.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
// Icon represents a generated jdenticon with its configuration and geometry
|
||||
type Icon struct {
|
||||
Hash string
|
||||
Size float64
|
||||
Config ColorConfig
|
||||
Shapes []ShapeGroup
|
||||
}
|
||||
|
||||
// ShapeGroup represents a group of shapes with the same color
|
||||
type ShapeGroup struct {
|
||||
Color Color
|
||||
Shapes []Shape
|
||||
ShapeType string
|
||||
}
|
||||
|
||||
// Shape represents a single geometric shape. It acts as a discriminated union
|
||||
// where the `Type` field determines which other fields are valid.
|
||||
// - For "polygon", `Points` is used.
|
||||
// - For "circle", `CircleX`, `CircleY`, and `CircleSize` are used.
|
||||
type Shape struct {
|
||||
Type string
|
||||
Points []Point
|
||||
Transform Transform
|
||||
Invert bool
|
||||
// Circle-specific fields
|
||||
CircleX float64
|
||||
CircleY float64
|
||||
CircleSize float64
|
||||
}
|
||||
|
||||
// Generator encapsulates the icon generation logic and provides caching
|
||||
type Generator struct {
|
||||
config ColorConfig
|
||||
cache map[string]*Icon
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewGenerator creates a new Generator with the specified configuration
|
||||
func NewGenerator(config ColorConfig) *Generator {
|
||||
config.Validate()
|
||||
return &Generator{
|
||||
config: config,
|
||||
cache: make(map[string]*Icon),
|
||||
}
|
||||
}
|
||||
|
||||
// NewDefaultGenerator creates a new Generator with default configuration
|
||||
func NewDefaultGenerator() *Generator {
|
||||
return NewGenerator(DefaultColorConfig())
|
||||
}
|
||||
|
||||
// Generate creates an icon from a hash string using the configured settings
|
||||
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
|
||||
if hash == "" {
|
||||
return nil, fmt.Errorf("hash cannot be empty")
|
||||
}
|
||||
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("size must be positive, got %f", size)
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := g.cacheKey(hash, size)
|
||||
g.mu.RLock()
|
||||
if cached, exists := g.cache[cacheKey]; exists {
|
||||
g.mu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
// Validate hash format
|
||||
if !util.IsValidHash(hash) {
|
||||
return nil, fmt.Errorf("invalid hash format: %s", hash)
|
||||
}
|
||||
|
||||
// Generate new icon
|
||||
icon, err := g.generateIcon(hash, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
g.mu.Lock()
|
||||
g.cache[cacheKey] = icon
|
||||
g.mu.Unlock()
|
||||
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
// generateIcon performs the actual icon generation
|
||||
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
|
||||
// Calculate padding and round to nearest integer (matching JavaScript)
|
||||
padding := int((0.5 + size*g.config.IconPadding))
|
||||
iconSize := size - float64(padding*2)
|
||||
|
||||
// Calculate cell size and ensure it is an integer (matching JavaScript)
|
||||
cell := int(iconSize / 4)
|
||||
|
||||
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
|
||||
x := int(float64(padding) + iconSize/2 - float64(cell*2))
|
||||
y := int(float64(padding) + iconSize/2 - float64(cell*2))
|
||||
|
||||
// Extract hue from hash (last 7 characters)
|
||||
hue, err := g.extractHue(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: %w", err)
|
||||
}
|
||||
|
||||
// Generate color theme
|
||||
availableColors := GenerateColorTheme(hue, g.config)
|
||||
|
||||
// Select colors for each shape layer
|
||||
selectedColorIndexes, err := g.selectColors(hash, availableColors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate shape groups in exact JavaScript order
|
||||
shapeGroups := make([]ShapeGroup, 0, 3)
|
||||
|
||||
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
|
||||
sideShapes, err := g.renderShape(hash, 0, 2, 3,
|
||||
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
|
||||
x, y, cell, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
|
||||
}
|
||||
if len(sideShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[0]],
|
||||
Shapes: sideShapes,
|
||||
ShapeType: "sides",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
|
||||
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
|
||||
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
|
||||
x, y, cell, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
|
||||
}
|
||||
if len(cornerShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[1]],
|
||||
Shapes: cornerShapes,
|
||||
ShapeType: "corners",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
|
||||
centerShapes, err := g.renderShape(hash, 2, 1, -1,
|
||||
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
|
||||
x, y, cell, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
|
||||
}
|
||||
if len(centerShapes) > 0 {
|
||||
shapeGroups = append(shapeGroups, ShapeGroup{
|
||||
Color: availableColors[selectedColorIndexes[2]],
|
||||
Shapes: centerShapes,
|
||||
ShapeType: "center",
|
||||
})
|
||||
}
|
||||
|
||||
return &Icon{
|
||||
Hash: hash,
|
||||
Size: size,
|
||||
Config: g.config,
|
||||
Shapes: shapeGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractHue extracts the hue value from the hash string
|
||||
func (g *Generator) extractHue(hash string) (float64, error) {
|
||||
// Use the last 7 characters of the hash to determine hue
|
||||
hueValue, err := util.ParseHex(hash, -7, 7)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("extractHue: %w", err)
|
||||
}
|
||||
return float64(hueValue) / 0xfffffff, nil
|
||||
}
|
||||
|
||||
// selectColors selects 3 colors from the available color palette
|
||||
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
|
||||
if len(availableColors) == 0 {
|
||||
return nil, fmt.Errorf("no available colors")
|
||||
}
|
||||
|
||||
selectedIndexes := make([]int, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
indexValue, err := util.ParseHex(hash, 8+i, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
|
||||
}
|
||||
index := indexValue % len(availableColors)
|
||||
|
||||
// Apply color conflict resolution rules from JavaScript implementation
|
||||
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
|
||||
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
|
||||
index = 1 // Use mid color as fallback
|
||||
}
|
||||
|
||||
selectedIndexes[i] = index
|
||||
}
|
||||
|
||||
return selectedIndexes, nil
|
||||
}
|
||||
|
||||
// contains checks if a slice contains a specific value
|
||||
func contains(slice []int, value int) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isDuplicateColor checks for problematic color combinations
|
||||
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
|
||||
if !contains(forbidden, index) {
|
||||
return false
|
||||
}
|
||||
for _, s := range selected {
|
||||
if contains(forbidden, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// renderShape implements the JavaScript renderShape function exactly
|
||||
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
|
||||
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
|
||||
}
|
||||
shapeIndex := shapeIndexValue
|
||||
|
||||
var rotation int
|
||||
if rotationHashIndex >= 0 {
|
||||
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
|
||||
}
|
||||
rotation = rotationValue
|
||||
}
|
||||
|
||||
shapes := make([]Shape, 0, len(positions))
|
||||
|
||||
for i, pos := range positions {
|
||||
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
|
||||
transformX := float64(x + pos[0]*cell)
|
||||
transformY := float64(y + pos[1]*cell)
|
||||
var transformRotation int
|
||||
if rotationHashIndex >= 0 {
|
||||
transformRotation = (rotation + i) % 4
|
||||
} else {
|
||||
// For center shapes (rotationIndex is null), r starts at 0 and increments
|
||||
transformRotation = i % 4
|
||||
}
|
||||
|
||||
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
|
||||
|
||||
// Create shape using graphics with transform
|
||||
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
|
||||
|
||||
if isOuter {
|
||||
RenderOuterShape(graphics, shapeIndex, float64(cell))
|
||||
} else {
|
||||
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
|
||||
}
|
||||
|
||||
collector := graphics.renderer.(*shapeCollector)
|
||||
for _, shape := range collector.shapes {
|
||||
shapes = append(shapes, shape)
|
||||
}
|
||||
}
|
||||
|
||||
return shapes, nil
|
||||
}
|
||||
|
||||
// shapeCollector implements Renderer interface to collect shapes during generation
|
||||
type shapeCollector struct {
|
||||
shapes []Shape
|
||||
}
|
||||
|
||||
func (sc *shapeCollector) AddPolygon(points []Point) {
|
||||
sc.shapes = append(sc.shapes, Shape{
|
||||
Type: "polygon",
|
||||
Points: points,
|
||||
})
|
||||
}
|
||||
|
||||
func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
|
||||
// Store circle with dedicated circle geometry fields
|
||||
sc.shapes = append(sc.shapes, Shape{
|
||||
Type: "circle",
|
||||
CircleX: topLeft.X,
|
||||
CircleY: topLeft.Y,
|
||||
CircleSize: size,
|
||||
Invert: invert,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// cacheKey generates a cache key for the given parameters
|
||||
func (g *Generator) cacheKey(hash string, size float64) string {
|
||||
return fmt.Sprintf("%s:%.2f", hash, size)
|
||||
}
|
||||
|
||||
// ClearCache clears the internal cache
|
||||
func (g *Generator) ClearCache() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cache = make(map[string]*Icon)
|
||||
}
|
||||
|
||||
// GetCacheSize returns the number of cached icons
|
||||
func (g *Generator) GetCacheSize() int {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return len(g.cache)
|
||||
}
|
||||
|
||||
// SetConfig updates the generator configuration and clears cache
|
||||
func (g *Generator) SetConfig(config ColorConfig) {
|
||||
config.Validate()
|
||||
g.mu.Lock()
|
||||
g.config = config
|
||||
g.cache = make(map[string]*Icon)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetConfig returns a copy of the current configuration
|
||||
func (g *Generator) GetConfig() ColorConfig {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.config
|
||||
}
|
||||
517
internal/engine/generator_test.go
Normal file
517
internal/engine/generator_test.go
Normal file
@@ -0,0 +1,517 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/util"
|
||||
)
|
||||
|
||||
func TestNewGenerator(t *testing.T) {
|
||||
config := DefaultColorConfig()
|
||||
generator := NewGenerator(config)
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewGenerator returned nil")
|
||||
}
|
||||
|
||||
if generator.config.IconPadding != config.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.IconPadding)
|
||||
}
|
||||
|
||||
if generator.cache == nil {
|
||||
t.Error("Generator cache was not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultGenerator(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
if generator == nil {
|
||||
t.Fatal("NewDefaultGenerator returned nil")
|
||||
}
|
||||
|
||||
expectedConfig := DefaultColorConfig()
|
||||
if generator.config.IconPadding != expectedConfig.IconPadding {
|
||||
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.IconPadding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateValidHash(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
icon, err := generator.Generate(hash, size)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed with error: %v", err)
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
t.Fatal("Generate returned nil icon")
|
||||
}
|
||||
|
||||
if icon.Hash != hash {
|
||||
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
|
||||
}
|
||||
|
||||
if icon.Size != size {
|
||||
t.Errorf("Expected size %f, got %f", size, icon.Size)
|
||||
}
|
||||
|
||||
if len(icon.Shapes) == 0 {
|
||||
t.Error("Generated icon has no shapes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInvalidInputs(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
size float64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty hash",
|
||||
hash: "",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
hash: "abcdef123456789",
|
||||
size: 0.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
hash: "abcdef123456789",
|
||||
size: -10.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "short hash",
|
||||
hash: "abc",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hex characters",
|
||||
hash: "xyz123456789abc",
|
||||
size: 64.0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := generator.Generate(tt.hash, tt.size)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCaching(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate icon first time
|
||||
icon1, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("First generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Check cache size
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
|
||||
}
|
||||
|
||||
// Generate same icon again
|
||||
icon2, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be the same instance from cache
|
||||
if icon1 != icon2 {
|
||||
t.Error("Second generate did not return cached instance")
|
||||
}
|
||||
|
||||
// Cache size should still be 1
|
||||
if generator.GetCacheSize() != 1 {
|
||||
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCache(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
generator.ClearCache()
|
||||
|
||||
// Verify cache is empty
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfig(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Generate an icon to populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache has content
|
||||
if generator.GetCacheSize() == 0 {
|
||||
t.Error("Cache should not be empty after generate")
|
||||
}
|
||||
|
||||
// Set new config
|
||||
newConfig := DefaultColorConfig()
|
||||
newConfig.IconPadding = 0.1
|
||||
generator.SetConfig(newConfig)
|
||||
|
||||
// Verify config was updated
|
||||
if generator.GetConfig().IconPadding != 0.1 {
|
||||
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
|
||||
}
|
||||
|
||||
// Verify cache was cleared
|
||||
if generator.GetCacheSize() != 0 {
|
||||
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHue(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
expected float64
|
||||
tolerance float64
|
||||
}{
|
||||
{
|
||||
name: "all zeros",
|
||||
hash: "0000000000000000000",
|
||||
expected: 0.0,
|
||||
tolerance: 0.0001,
|
||||
},
|
||||
{
|
||||
name: "all fs",
|
||||
hash: "ffffffffffffffffff",
|
||||
expected: 1.0,
|
||||
tolerance: 0.0001,
|
||||
},
|
||||
{
|
||||
name: "half value",
|
||||
hash: "000000000007ffffff",
|
||||
expected: 0.5,
|
||||
tolerance: 0.001, // Allow small floating point variance
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := generator.extractHue(tt.hash)
|
||||
if err != nil {
|
||||
t.Fatalf("extractHue failed: %v", err)
|
||||
}
|
||||
diff := result - tt.expected
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > tt.tolerance {
|
||||
t.Errorf("Expected hue %f, got %f (tolerance %f)", tt.expected, result, tt.tolerance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColors(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "123456789abcdef"
|
||||
|
||||
// Create test color palette
|
||||
availableColors := []Color{
|
||||
NewColorRGB(50, 50, 50), // 0: Dark gray
|
||||
NewColorRGB(100, 100, 200), // 1: Mid color
|
||||
NewColorRGB(200, 200, 200), // 2: Light gray
|
||||
NewColorRGB(150, 150, 255), // 3: Light color
|
||||
NewColorRGB(25, 25, 100), // 4: Dark color
|
||||
}
|
||||
|
||||
selectedIndexes, err := generator.selectColors(hash, availableColors)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("selectColors failed: %v", err)
|
||||
}
|
||||
|
||||
if len(selectedIndexes) != 3 {
|
||||
t.Fatalf("Expected 3 selected colors, got %d", len(selectedIndexes))
|
||||
}
|
||||
|
||||
for i, index := range selectedIndexes {
|
||||
if index < 0 || index >= len(availableColors) {
|
||||
t.Errorf("Color index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectColorsEmptyPalette(t *testing.T) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "123456789abcdef"
|
||||
|
||||
_, err := generator.selectColors(hash, []Color{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty color palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHash(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "valid hash",
|
||||
hash: "abcdef123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
hash: "abc",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid characters",
|
||||
hash: "xyz123456789abc",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase valid",
|
||||
hash: "ABCDEF123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "mixed case valid",
|
||||
hash: "AbCdEf123456789",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
hash: "",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := util.IsValidHash(tt.hash)
|
||||
if result != tt.valid {
|
||||
t.Errorf("Expected isValidHash(%s) = %v, got %v", tt.hash, tt.valid, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHex(t *testing.T) {
|
||||
hash := "123456789abcdef"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start int
|
||||
octets int
|
||||
expected int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single character",
|
||||
start: 0,
|
||||
octets: 1,
|
||||
expected: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "two characters",
|
||||
start: 1,
|
||||
octets: 2,
|
||||
expected: 0x23,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
start: -1,
|
||||
octets: 1,
|
||||
expected: 0xf,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "out of bounds",
|
||||
start: 100,
|
||||
octets: 1,
|
||||
expected: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := util.ParseHex(hash, tt.start, tt.octets)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error, but got nil")
|
||||
}
|
||||
return // Test is done for error cases
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseHex failed unexpectedly: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeCollector(t *testing.T) {
|
||||
collector := &shapeCollector{}
|
||||
|
||||
// Test AddPolygon
|
||||
points := []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}}
|
||||
collector.AddPolygon(points)
|
||||
|
||||
if len(collector.shapes) != 1 {
|
||||
t.Fatalf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
shape := collector.shapes[0]
|
||||
if shape.Type != "polygon" {
|
||||
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
|
||||
}
|
||||
|
||||
if len(shape.Points) != len(points) {
|
||||
t.Errorf("Expected %d points, got %d", len(points), len(shape.Points))
|
||||
}
|
||||
|
||||
// Test AddCircle
|
||||
center := Point{X: 5, Y: 5}
|
||||
radius := 2.5
|
||||
collector.AddCircle(center, radius, false)
|
||||
|
||||
if len(collector.shapes) != 2 {
|
||||
t.Fatalf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
|
||||
}
|
||||
|
||||
circleShape := collector.shapes[1]
|
||||
if circleShape.Type != "circle" {
|
||||
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
|
||||
}
|
||||
|
||||
// Verify circle fields are set correctly
|
||||
if circleShape.CircleX != center.X {
|
||||
t.Errorf("Expected CircleX %f, got %f", center.X, circleShape.CircleX)
|
||||
}
|
||||
if circleShape.CircleY != center.Y {
|
||||
t.Errorf("Expected CircleY %f, got %f", center.Y, circleShape.CircleY)
|
||||
}
|
||||
if circleShape.CircleSize != radius {
|
||||
t.Errorf("Expected CircleSize %f, got %f", radius, circleShape.CircleSize)
|
||||
}
|
||||
if circleShape.Invert != false {
|
||||
t.Errorf("Expected Invert false, got %t", circleShape.Invert)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate(b *testing.B) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateWithCache(b *testing.B) {
|
||||
generator := NewDefaultGenerator()
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
// Pre-populate cache
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Initial generate failed: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.Generate(hash, size)
|
||||
if err != nil {
|
||||
b.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsistentGeneration(t *testing.T) {
|
||||
generator1 := NewDefaultGenerator()
|
||||
generator2 := NewDefaultGenerator()
|
||||
|
||||
hash := "abcdef123456789"
|
||||
size := 64.0
|
||||
|
||||
icon1, err := generator1.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generator1 failed: %v", err)
|
||||
}
|
||||
|
||||
icon2, err := generator2.Generate(hash, size)
|
||||
if err != nil {
|
||||
t.Fatalf("Generator2 failed: %v", err)
|
||||
}
|
||||
|
||||
// Icons should have same number of shape groups
|
||||
if len(icon1.Shapes) != len(icon2.Shapes) {
|
||||
t.Errorf("Different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
|
||||
}
|
||||
|
||||
// Colors should be the same
|
||||
for i := range icon1.Shapes {
|
||||
if i >= len(icon2.Shapes) {
|
||||
break
|
||||
}
|
||||
if !icon1.Shapes[i].Color.Equals(icon2.Shapes[i].Color) {
|
||||
t.Errorf("Different colors at group %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
internal/engine/layout.go
Normal file
136
internal/engine/layout.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package engine
|
||||
|
||||
// Grid represents a 4x4 layout grid for positioning shapes in a jdenticon
|
||||
type Grid struct {
|
||||
Size float64
|
||||
Cell int
|
||||
X int
|
||||
Y int
|
||||
Padding int
|
||||
}
|
||||
|
||||
// Position represents an x, y coordinate pair
|
||||
type Position struct {
|
||||
X, Y int
|
||||
}
|
||||
|
||||
// NewGrid creates a new Grid with the specified icon size and padding ratio
|
||||
func NewGrid(iconSize float64, paddingRatio float64) *Grid {
|
||||
// Calculate padding and round to nearest integer (matches JS: (0.5 + size * parsedConfig.iconPadding) | 0)
|
||||
padding := int(0.5 + iconSize*paddingRatio)
|
||||
size := iconSize - float64(padding*2)
|
||||
|
||||
// Calculate cell size and ensure it is an integer (matches JS: 0 | (size / 4))
|
||||
cell := int(size / 4)
|
||||
|
||||
// Center the icon since cell size is integer-based (matches JS implementation)
|
||||
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
|
||||
x := padding + int((size - float64(cell*4))/2)
|
||||
y := padding + int((size - float64(cell*4))/2)
|
||||
|
||||
return &Grid{
|
||||
Size: size,
|
||||
Cell: cell,
|
||||
X: x,
|
||||
Y: y,
|
||||
Padding: padding,
|
||||
}
|
||||
}
|
||||
|
||||
// CellToCoordinate converts a grid cell position to actual coordinates
|
||||
func (g *Grid) CellToCoordinate(cellX, cellY int) (x, y float64) {
|
||||
return float64(g.X + cellX*g.Cell), float64(g.Y + cellY*g.Cell)
|
||||
}
|
||||
|
||||
// GetCellSize returns the size of each cell in the grid
|
||||
func (g *Grid) GetCellSize() float64 {
|
||||
return float64(g.Cell)
|
||||
}
|
||||
|
||||
// LayoutEngine manages the overall layout and positioning of icon elements
|
||||
type LayoutEngine struct {
|
||||
grid *Grid
|
||||
}
|
||||
|
||||
// NewLayoutEngine creates a new LayoutEngine with the specified parameters
|
||||
func NewLayoutEngine(iconSize float64, paddingRatio float64) *LayoutEngine {
|
||||
return &LayoutEngine{
|
||||
grid: NewGrid(iconSize, paddingRatio),
|
||||
}
|
||||
}
|
||||
|
||||
// Grid returns the underlying grid
|
||||
func (le *LayoutEngine) Grid() *Grid {
|
||||
return le.grid
|
||||
}
|
||||
|
||||
// GetShapePositions returns the positions for different shape types based on the jdenticon pattern
|
||||
func (le *LayoutEngine) GetShapePositions(shapeType string) []Position {
|
||||
switch shapeType {
|
||||
case "sides":
|
||||
// Sides: positions around the perimeter (8 positions)
|
||||
return []Position{
|
||||
{1, 0}, {2, 0}, {2, 3}, {1, 3}, // top and bottom
|
||||
{0, 1}, {3, 1}, {3, 2}, {0, 2}, // left and right
|
||||
}
|
||||
case "corners":
|
||||
// Corners: four corner positions
|
||||
return []Position{
|
||||
{0, 0}, {3, 0}, {3, 3}, {0, 3},
|
||||
}
|
||||
case "center":
|
||||
// Center: four center positions
|
||||
return []Position{
|
||||
{1, 1}, {2, 1}, {2, 2}, {1, 2},
|
||||
}
|
||||
default:
|
||||
return []Position{}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplySymmetry applies symmetrical transformations to position indices
|
||||
// This ensures the icon has the characteristic jdenticon symmetry
|
||||
func ApplySymmetry(positions []Position, index int) []Position {
|
||||
if index >= len(positions) {
|
||||
return positions
|
||||
}
|
||||
|
||||
// For jdenticon, we apply rotational symmetry
|
||||
// The pattern is designed to be symmetrical, so we don't need to modify positions
|
||||
// The symmetry is achieved through the predefined position arrays
|
||||
return positions
|
||||
}
|
||||
|
||||
// GetTransformedPosition applies rotation and returns the final position
|
||||
func (le *LayoutEngine) GetTransformedPosition(cellX, cellY int, rotation int) (x, y float64, cellSize float64) {
|
||||
// Apply rotation if needed (rotation is 0-3 for 0°, 90°, 180°, 270°)
|
||||
switch rotation % 4 {
|
||||
case 0: // 0°
|
||||
// No rotation
|
||||
case 1: // 90° clockwise
|
||||
cellX, cellY = cellY, 3-cellX
|
||||
case 2: // 180°
|
||||
cellX, cellY = 3-cellX, 3-cellY
|
||||
case 3: // 270° clockwise (90° counter-clockwise)
|
||||
cellX, cellY = 3-cellY, cellX
|
||||
}
|
||||
|
||||
x, y = le.grid.CellToCoordinate(cellX, cellY)
|
||||
cellSize = le.grid.GetCellSize()
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateGrid checks if the grid configuration is valid
|
||||
func (g *Grid) ValidateGrid() bool {
|
||||
return g.Cell > 0 && g.Size > 0 && g.Padding >= 0
|
||||
}
|
||||
|
||||
// GetIconBounds returns the bounds of the icon within the grid
|
||||
func (g *Grid) GetIconBounds() (x, y, width, height float64) {
|
||||
return float64(g.X), float64(g.Y), float64(g.Cell * 4), float64(g.Cell * 4)
|
||||
}
|
||||
|
||||
// GetCenterOffset returns the offset needed to center content within a cell
|
||||
func (g *Grid) GetCenterOffset() (dx, dy float64) {
|
||||
return float64(g.Cell) / 2, float64(g.Cell) / 2
|
||||
}
|
||||
380
internal/engine/layout_test.go
Normal file
380
internal/engine/layout_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewGrid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iconSize float64
|
||||
paddingRatio float64
|
||||
wantPadding int
|
||||
wantCell int
|
||||
}{
|
||||
{
|
||||
name: "standard 64px icon with 8% padding",
|
||||
iconSize: 64.0,
|
||||
paddingRatio: 0.08,
|
||||
wantPadding: 5, // 0.5 + 64 * 0.08 = 5.62, rounded to 5
|
||||
wantCell: 13, // (64 - 5*2) / 4 = 54/4 = 13.5, truncated to 13
|
||||
},
|
||||
{
|
||||
name: "large 256px icon with 10% padding",
|
||||
iconSize: 256.0,
|
||||
paddingRatio: 0.10,
|
||||
wantPadding: 26, // 0.5 + 256 * 0.10 = 26.1, rounded to 26
|
||||
wantCell: 51, // (256 - 26*2) / 4 = 204/4 = 51
|
||||
},
|
||||
{
|
||||
name: "small 32px icon with 5% padding",
|
||||
iconSize: 32.0,
|
||||
paddingRatio: 0.05,
|
||||
wantPadding: 2, // 0.5 + 32 * 0.05 = 2.1, rounded to 2
|
||||
wantCell: 7, // (32 - 2*2) / 4 = 28/4 = 7
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
grid := NewGrid(tt.iconSize, tt.paddingRatio)
|
||||
|
||||
if grid.Padding != tt.wantPadding {
|
||||
t.Errorf("NewGrid() padding = %v, want %v", grid.Padding, tt.wantPadding)
|
||||
}
|
||||
|
||||
if grid.Cell != tt.wantCell {
|
||||
t.Errorf("NewGrid() cell = %v, want %v", grid.Cell, tt.wantCell)
|
||||
}
|
||||
|
||||
// Verify that the grid is centered
|
||||
expectedSize := tt.iconSize - float64(tt.wantPadding*2)
|
||||
if math.Abs(grid.Size-expectedSize) > 0.1 {
|
||||
t.Errorf("NewGrid() size = %v, want %v", grid.Size, expectedSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridCellToCoordinate(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cellX int
|
||||
cellY int
|
||||
wantX float64
|
||||
wantY float64
|
||||
}{
|
||||
{
|
||||
name: "origin cell (0,0)",
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
wantX: float64(grid.X),
|
||||
wantY: float64(grid.Y),
|
||||
},
|
||||
{
|
||||
name: "center cell (1,1)",
|
||||
cellX: 1,
|
||||
cellY: 1,
|
||||
wantX: float64(grid.X + grid.Cell),
|
||||
wantY: float64(grid.Y + grid.Cell),
|
||||
},
|
||||
{
|
||||
name: "corner cell (3,3)",
|
||||
cellX: 3,
|
||||
cellY: 3,
|
||||
wantX: float64(grid.X + 3*grid.Cell),
|
||||
wantY: float64(grid.Y + 3*grid.Cell),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotX, gotY := grid.CellToCoordinate(tt.cellX, tt.cellY)
|
||||
|
||||
if gotX != tt.wantX {
|
||||
t.Errorf("CellToCoordinate() x = %v, want %v", gotX, tt.wantX)
|
||||
}
|
||||
|
||||
if gotY != tt.wantY {
|
||||
t.Errorf("CellToCoordinate() y = %v, want %v", gotY, tt.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutEngineGetShapePositions(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shapeType string
|
||||
wantLen int
|
||||
wantFirst Position
|
||||
wantLast Position
|
||||
}{
|
||||
{
|
||||
name: "sides positions",
|
||||
shapeType: "sides",
|
||||
wantLen: 8,
|
||||
wantFirst: Position{1, 0},
|
||||
wantLast: Position{0, 2},
|
||||
},
|
||||
{
|
||||
name: "corners positions",
|
||||
shapeType: "corners",
|
||||
wantLen: 4,
|
||||
wantFirst: Position{0, 0},
|
||||
wantLast: Position{0, 3},
|
||||
},
|
||||
{
|
||||
name: "center positions",
|
||||
shapeType: "center",
|
||||
wantLen: 4,
|
||||
wantFirst: Position{1, 1},
|
||||
wantLast: Position{1, 2},
|
||||
},
|
||||
{
|
||||
name: "invalid shape type",
|
||||
shapeType: "invalid",
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positions := le.GetShapePositions(tt.shapeType)
|
||||
|
||||
if len(positions) != tt.wantLen {
|
||||
t.Errorf("GetShapePositions() len = %v, want %v", len(positions), tt.wantLen)
|
||||
}
|
||||
|
||||
if tt.wantLen > 0 {
|
||||
if positions[0] != tt.wantFirst {
|
||||
t.Errorf("GetShapePositions() first = %v, want %v", positions[0], tt.wantFirst)
|
||||
}
|
||||
|
||||
if positions[len(positions)-1] != tt.wantLast {
|
||||
t.Errorf("GetShapePositions() last = %v, want %v", positions[len(positions)-1], tt.wantLast)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutEngineGetTransformedPosition(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cellX int
|
||||
cellY int
|
||||
rotation int
|
||||
wantX int // Expected cell X after rotation
|
||||
wantY int // Expected cell Y after rotation
|
||||
}{
|
||||
{
|
||||
name: "no rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 0,
|
||||
wantX: 1,
|
||||
wantY: 0,
|
||||
},
|
||||
{
|
||||
name: "90 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 1,
|
||||
wantX: 0,
|
||||
wantY: 2, // 3-1 = 2
|
||||
},
|
||||
{
|
||||
name: "180 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 2,
|
||||
wantX: 2, // 3-1 = 2
|
||||
wantY: 3, // 3-0 = 3
|
||||
},
|
||||
{
|
||||
name: "270 degree rotation",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 3,
|
||||
wantX: 3, // 3-0 = 3
|
||||
wantY: 1,
|
||||
},
|
||||
{
|
||||
name: "rotation overflow (4 = 0)",
|
||||
cellX: 1,
|
||||
cellY: 0,
|
||||
rotation: 4,
|
||||
wantX: 1,
|
||||
wantY: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotX, gotY, gotCellSize := le.GetTransformedPosition(tt.cellX, tt.cellY, tt.rotation)
|
||||
|
||||
// Convert back to cell coordinates to verify rotation
|
||||
expectedX, expectedY := le.grid.CellToCoordinate(tt.wantX, tt.wantY)
|
||||
|
||||
if gotX != expectedX {
|
||||
t.Errorf("GetTransformedPosition() x = %v, want %v", gotX, expectedX)
|
||||
}
|
||||
|
||||
if gotY != expectedY {
|
||||
t.Errorf("GetTransformedPosition() y = %v, want %v", gotY, expectedY)
|
||||
}
|
||||
|
||||
if gotCellSize != float64(le.grid.Cell) {
|
||||
t.Errorf("GetTransformedPosition() cellSize = %v, want %v", gotCellSize, float64(le.grid.Cell))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySymmetry(t *testing.T) {
|
||||
positions := []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
want int // expected length
|
||||
}{
|
||||
{
|
||||
name: "valid index",
|
||||
index: 1,
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "index out of bounds",
|
||||
index: 10,
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
index: -1,
|
||||
want: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ApplySymmetry(positions, tt.index)
|
||||
|
||||
if len(result) != tt.want {
|
||||
t.Errorf("ApplySymmetry() len = %v, want %v", len(result), tt.want)
|
||||
}
|
||||
|
||||
// Verify that the positions are unchanged (current implementation)
|
||||
for i, pos := range result {
|
||||
if pos != positions[i] {
|
||||
t.Errorf("ApplySymmetry() changed position at index %d: got %v, want %v", i, pos, positions[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridValidateGrid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
grid *Grid
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid grid",
|
||||
grid: &Grid{Size: 64, Cell: 16, Padding: 4},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero cell size",
|
||||
grid: &Grid{Size: 64, Cell: 0, Padding: 4},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
grid: &Grid{Size: 0, Cell: 16, Padding: 4},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negative padding",
|
||||
grid: &Grid{Size: 64, Cell: 16, Padding: -1},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.grid.ValidateGrid(); got != tt.want {
|
||||
t.Errorf("ValidateGrid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridGetIconBounds(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
x, y, width, height := grid.GetIconBounds()
|
||||
|
||||
expectedX := float64(grid.X)
|
||||
expectedY := float64(grid.Y)
|
||||
expectedWidth := float64(grid.Cell * 4)
|
||||
expectedHeight := float64(grid.Cell * 4)
|
||||
|
||||
if x != expectedX {
|
||||
t.Errorf("GetIconBounds() x = %v, want %v", x, expectedX)
|
||||
}
|
||||
|
||||
if y != expectedY {
|
||||
t.Errorf("GetIconBounds() y = %v, want %v", y, expectedY)
|
||||
}
|
||||
|
||||
if width != expectedWidth {
|
||||
t.Errorf("GetIconBounds() width = %v, want %v", width, expectedWidth)
|
||||
}
|
||||
|
||||
if height != expectedHeight {
|
||||
t.Errorf("GetIconBounds() height = %v, want %v", height, expectedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridGetCenterOffset(t *testing.T) {
|
||||
grid := NewGrid(64.0, 0.08)
|
||||
|
||||
dx, dy := grid.GetCenterOffset()
|
||||
|
||||
expected := float64(grid.Cell) / 2
|
||||
|
||||
if dx != expected {
|
||||
t.Errorf("GetCenterOffset() dx = %v, want %v", dx, expected)
|
||||
}
|
||||
|
||||
if dy != expected {
|
||||
t.Errorf("GetCenterOffset() dy = %v, want %v", dy, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLayoutEngine(t *testing.T) {
|
||||
le := NewLayoutEngine(64.0, 0.08)
|
||||
|
||||
if le.grid == nil {
|
||||
t.Error("NewLayoutEngine() grid is nil")
|
||||
}
|
||||
|
||||
if le.Grid() != le.grid {
|
||||
t.Error("NewLayoutEngine() Grid() does not return internal grid")
|
||||
}
|
||||
|
||||
// Verify grid configuration
|
||||
if !le.grid.ValidateGrid() {
|
||||
t.Error("NewLayoutEngine() created invalid grid")
|
||||
}
|
||||
}
|
||||
266
internal/engine/shapes.go
Normal file
266
internal/engine/shapes.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package engine
|
||||
|
||||
import "math"
|
||||
|
||||
// Point represents a 2D point
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
// Renderer interface defines the methods that a renderer must implement
|
||||
type Renderer interface {
|
||||
AddPolygon(points []Point)
|
||||
AddCircle(topLeft Point, size float64, invert bool)
|
||||
}
|
||||
|
||||
// Graphics provides helper functions for rendering common basic shapes
|
||||
type Graphics struct {
|
||||
renderer Renderer
|
||||
currentTransform Transform
|
||||
}
|
||||
|
||||
// NewGraphics creates a new Graphics instance with the given renderer
|
||||
func NewGraphics(renderer Renderer) *Graphics {
|
||||
return &Graphics{
|
||||
renderer: renderer,
|
||||
currentTransform: NoTransform,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGraphicsWithTransform creates a new Graphics instance with the given renderer and transform
|
||||
func NewGraphicsWithTransform(renderer Renderer, transform Transform) *Graphics {
|
||||
return &Graphics{
|
||||
renderer: renderer,
|
||||
currentTransform: transform,
|
||||
}
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon to the underlying renderer
|
||||
func (g *Graphics) AddPolygon(points []Point, invert bool) {
|
||||
// Transform all points
|
||||
transformedPoints := make([]Point, len(points))
|
||||
if invert {
|
||||
// Reverse the order and transform
|
||||
for i, p := range points {
|
||||
transformedPoints[len(points)-1-i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
|
||||
}
|
||||
} else {
|
||||
// Transform in order
|
||||
for i, p := range points {
|
||||
transformedPoints[i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
|
||||
}
|
||||
}
|
||||
g.renderer.AddPolygon(transformedPoints)
|
||||
}
|
||||
|
||||
// AddCircle adds a circle to the underlying renderer
|
||||
func (g *Graphics) AddCircle(x, y, size float64, invert bool) {
|
||||
// Transform the circle position
|
||||
transformedPoint := g.currentTransform.TransformIconPoint(x, y, size, size)
|
||||
g.renderer.AddCircle(transformedPoint, size, invert)
|
||||
}
|
||||
|
||||
// AddRectangle adds a rectangle to the underlying renderer
|
||||
func (g *Graphics) AddRectangle(x, y, w, h float64, invert bool) {
|
||||
points := []Point{
|
||||
{X: x, Y: y},
|
||||
{X: x + w, Y: y},
|
||||
{X: x + w, Y: y + h},
|
||||
{X: x, Y: y + h},
|
||||
}
|
||||
g.AddPolygon(points, invert)
|
||||
}
|
||||
|
||||
// AddTriangle adds a right triangle to the underlying renderer
|
||||
// r is the rotation (0-3), where 0 = right corner in lower left
|
||||
func (g *Graphics) AddTriangle(x, y, w, h float64, r int, invert bool) {
|
||||
points := []Point{
|
||||
{X: x + w, Y: y},
|
||||
{X: x + w, Y: y + h},
|
||||
{X: x, Y: y + h},
|
||||
{X: x, Y: y},
|
||||
}
|
||||
|
||||
// Remove one corner based on rotation
|
||||
removeIndex := (r % 4) * 1
|
||||
if removeIndex < len(points) {
|
||||
points = append(points[:removeIndex], points[removeIndex+1:]...)
|
||||
}
|
||||
|
||||
g.AddPolygon(points, invert)
|
||||
}
|
||||
|
||||
// AddRhombus adds a rhombus (diamond) to the underlying renderer
|
||||
func (g *Graphics) AddRhombus(x, y, w, h float64, invert bool) {
|
||||
points := []Point{
|
||||
{X: x + w/2, Y: y},
|
||||
{X: x + w, Y: y + h/2},
|
||||
{X: x + w/2, Y: y + h},
|
||||
{X: x, Y: y + h/2},
|
||||
}
|
||||
g.AddPolygon(points, invert)
|
||||
}
|
||||
|
||||
// RenderCenterShape renders one of the 14 distinct center shape patterns
|
||||
func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64) {
|
||||
index := shapeIndex % 14
|
||||
|
||||
switch index {
|
||||
case 0:
|
||||
// Shape 0: Asymmetric polygon
|
||||
k := cell * 0.42
|
||||
points := []Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: cell, Y: 0},
|
||||
{X: cell, Y: cell - k*2},
|
||||
{X: cell - k, Y: cell},
|
||||
{X: 0, Y: cell},
|
||||
}
|
||||
g.AddPolygon(points, false)
|
||||
|
||||
case 1:
|
||||
// Shape 1: Triangle
|
||||
w := math.Floor(cell * 0.5)
|
||||
h := math.Floor(cell * 0.8)
|
||||
g.AddTriangle(cell-w, 0, w, h, 2, false)
|
||||
|
||||
case 2:
|
||||
// Shape 2: Rectangle
|
||||
w := math.Floor(cell / 3)
|
||||
g.AddRectangle(w, w, cell-w, cell-w, false)
|
||||
|
||||
case 3:
|
||||
// Shape 3: Nested rectangles
|
||||
inner := cell * 0.1
|
||||
var outer float64
|
||||
if cell < 6 {
|
||||
outer = 1
|
||||
} else if cell < 8 {
|
||||
outer = 2
|
||||
} else {
|
||||
outer = math.Floor(cell * 0.25)
|
||||
}
|
||||
|
||||
if inner > 1 {
|
||||
inner = math.Floor(inner)
|
||||
} else if inner > 0.5 {
|
||||
inner = 1
|
||||
}
|
||||
|
||||
g.AddRectangle(outer, outer, cell-inner-outer, cell-inner-outer, false)
|
||||
|
||||
case 4:
|
||||
// Shape 4: Circle
|
||||
m := math.Floor(cell * 0.15)
|
||||
w := math.Floor(cell * 0.5)
|
||||
g.AddCircle(cell-w-m, cell-w-m, w, false)
|
||||
|
||||
case 5:
|
||||
// Shape 5: Rectangle with triangular cutout
|
||||
inner := cell * 0.1
|
||||
outer := inner * 4
|
||||
|
||||
if outer > 3 {
|
||||
outer = math.Floor(outer)
|
||||
}
|
||||
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
points := []Point{
|
||||
{X: outer, Y: outer},
|
||||
{X: cell - inner, Y: outer},
|
||||
{X: outer + (cell-outer-inner)/2, Y: cell - inner},
|
||||
}
|
||||
g.AddPolygon(points, true)
|
||||
|
||||
case 6:
|
||||
// Shape 6: Complex polygon
|
||||
points := []Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: cell, Y: 0},
|
||||
{X: cell, Y: cell * 0.7},
|
||||
{X: cell * 0.4, Y: cell * 0.4},
|
||||
{X: cell * 0.7, Y: cell},
|
||||
{X: 0, Y: cell},
|
||||
}
|
||||
g.AddPolygon(points, false)
|
||||
|
||||
case 7:
|
||||
// Shape 7: Small triangle
|
||||
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
|
||||
|
||||
case 8:
|
||||
// Shape 8: Composite shape
|
||||
g.AddRectangle(0, 0, cell, cell/2, false)
|
||||
g.AddRectangle(0, cell/2, cell/2, cell/2, false)
|
||||
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 1, false)
|
||||
|
||||
case 9:
|
||||
// Shape 9: Rectangle with rectangular cutout
|
||||
inner := cell * 0.14
|
||||
var outer float64
|
||||
if cell < 4 {
|
||||
outer = 1
|
||||
} else if cell < 6 {
|
||||
outer = 2
|
||||
} else {
|
||||
outer = math.Floor(cell * 0.35)
|
||||
}
|
||||
|
||||
if cell >= 8 {
|
||||
inner = math.Floor(inner)
|
||||
}
|
||||
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
g.AddRectangle(outer, outer, cell-outer-inner, cell-outer-inner, true)
|
||||
|
||||
case 10:
|
||||
// Shape 10: Rectangle with circular cutout
|
||||
inner := cell * 0.12
|
||||
outer := inner * 3
|
||||
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
g.AddCircle(outer, outer, cell-inner-outer, true)
|
||||
|
||||
case 11:
|
||||
// Shape 11: Small triangle (same as 7)
|
||||
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
|
||||
|
||||
case 12:
|
||||
// Shape 12: Rectangle with rhombus cutout
|
||||
m := cell * 0.25
|
||||
g.AddRectangle(0, 0, cell, cell, false)
|
||||
g.AddRhombus(m, m, cell-m, cell-m, true)
|
||||
|
||||
case 13:
|
||||
// Shape 13: Large circle (only for position 0)
|
||||
if positionIndex == 0 {
|
||||
m := cell * 0.4
|
||||
w := cell * 1.2
|
||||
g.AddCircle(m, m, w, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RenderOuterShape renders one of the 4 distinct outer shape patterns
|
||||
func RenderOuterShape(g *Graphics, shapeIndex int, cell float64) {
|
||||
index := shapeIndex % 4
|
||||
|
||||
switch index {
|
||||
case 0:
|
||||
// Shape 0: Triangle
|
||||
g.AddTriangle(0, 0, cell, cell, 0, false)
|
||||
|
||||
case 1:
|
||||
// Shape 1: Triangle (different orientation)
|
||||
g.AddTriangle(0, cell/2, cell, cell/2, 0, false)
|
||||
|
||||
case 2:
|
||||
// Shape 2: Rhombus
|
||||
g.AddRhombus(0, 0, cell, cell, false)
|
||||
|
||||
case 3:
|
||||
// Shape 3: Circle
|
||||
m := cell / 6
|
||||
g.AddCircle(m, m, cell-2*m, false)
|
||||
}
|
||||
}
|
||||
257
internal/engine/shapes_test.go
Normal file
257
internal/engine/shapes_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MockRenderer implements the Renderer interface for testing
|
||||
type MockRenderer struct {
|
||||
Polygons [][]Point
|
||||
Circles []MockCircle
|
||||
}
|
||||
|
||||
type MockCircle struct {
|
||||
TopLeft Point
|
||||
Size float64
|
||||
Invert bool
|
||||
}
|
||||
|
||||
func (m *MockRenderer) AddPolygon(points []Point) {
|
||||
m.Polygons = append(m.Polygons, points)
|
||||
}
|
||||
|
||||
func (m *MockRenderer) AddCircle(topLeft Point, size float64, invert bool) {
|
||||
m.Circles = append(m.Circles, MockCircle{
|
||||
TopLeft: topLeft,
|
||||
Size: size,
|
||||
Invert: invert,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MockRenderer) Reset() {
|
||||
m.Polygons = nil
|
||||
m.Circles = nil
|
||||
}
|
||||
|
||||
func TestGraphicsAddRectangle(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
|
||||
g.AddRectangle(10, 20, 30, 40, false)
|
||||
|
||||
if len(mock.Polygons) != 1 {
|
||||
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
|
||||
return
|
||||
}
|
||||
|
||||
expected := []Point{
|
||||
{X: 10, Y: 20},
|
||||
{X: 40, Y: 20},
|
||||
{X: 40, Y: 60},
|
||||
{X: 10, Y: 60},
|
||||
}
|
||||
|
||||
polygon := mock.Polygons[0]
|
||||
if len(polygon) != len(expected) {
|
||||
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
|
||||
return
|
||||
}
|
||||
|
||||
for i, point := range expected {
|
||||
if polygon[i].X != point.X || polygon[i].Y != point.Y {
|
||||
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
|
||||
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphicsAddCircle(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
|
||||
g.AddCircle(10, 20, 30, false)
|
||||
|
||||
if len(mock.Circles) != 1 {
|
||||
t.Errorf("Expected 1 circle, got %d", len(mock.Circles))
|
||||
return
|
||||
}
|
||||
|
||||
circle := mock.Circles[0]
|
||||
expectedTopLeft := Point{X: 10, Y: 20}
|
||||
expectedSize := float64(30)
|
||||
|
||||
if circle.TopLeft.X != expectedTopLeft.X || circle.TopLeft.Y != expectedTopLeft.Y {
|
||||
t.Errorf("Expected top-left (%f, %f), got (%f, %f)",
|
||||
expectedTopLeft.X, expectedTopLeft.Y, circle.TopLeft.X, circle.TopLeft.Y)
|
||||
}
|
||||
|
||||
if circle.Size != expectedSize {
|
||||
t.Errorf("Expected size %f, got %f", expectedSize, circle.Size)
|
||||
}
|
||||
|
||||
if circle.Invert != false {
|
||||
t.Errorf("Expected invert false, got %t", circle.Invert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphicsAddRhombus(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
|
||||
g.AddRhombus(0, 0, 20, 30, false)
|
||||
|
||||
if len(mock.Polygons) != 1 {
|
||||
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
|
||||
return
|
||||
}
|
||||
|
||||
expected := []Point{
|
||||
{X: 10, Y: 0}, // top
|
||||
{X: 20, Y: 15}, // right
|
||||
{X: 10, Y: 30}, // bottom
|
||||
{X: 0, Y: 15}, // left
|
||||
}
|
||||
|
||||
polygon := mock.Polygons[0]
|
||||
if len(polygon) != len(expected) {
|
||||
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
|
||||
return
|
||||
}
|
||||
|
||||
for i, point := range expected {
|
||||
if polygon[i].X != point.X || polygon[i].Y != point.Y {
|
||||
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
|
||||
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCenterShape(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
cell := float64(60)
|
||||
|
||||
// Test each center shape
|
||||
for i := 0; i < 14; i++ {
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, i, cell, 0)
|
||||
|
||||
// Verify that some drawing commands were issued
|
||||
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
|
||||
// Shape 13 at position != 0 doesn't draw anything, which is expected
|
||||
if i == 13 {
|
||||
continue
|
||||
}
|
||||
t.Errorf("Shape %d: expected some drawing commands, got none", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCenterShapeSpecific(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
cell := float64(60)
|
||||
|
||||
// Test shape 2 (rectangle)
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 2, cell, 0)
|
||||
|
||||
if len(mock.Polygons) != 1 {
|
||||
t.Errorf("Shape 2: expected 1 polygon, got %d", len(mock.Polygons))
|
||||
}
|
||||
|
||||
// Test shape 4 (circle)
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 4, cell, 0)
|
||||
|
||||
if len(mock.Circles) != 1 {
|
||||
t.Errorf("Shape 4: expected 1 circle, got %d", len(mock.Circles))
|
||||
}
|
||||
|
||||
// Test shape 13 at position 0 (should draw)
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 13, cell, 0)
|
||||
|
||||
if len(mock.Circles) != 1 {
|
||||
t.Errorf("Shape 13 at position 0: expected 1 circle, got %d", len(mock.Circles))
|
||||
}
|
||||
|
||||
// Test shape 13 at position 1 (should not draw)
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 13, cell, 1)
|
||||
|
||||
if len(mock.Circles) != 0 {
|
||||
t.Errorf("Shape 13 at position 1: expected 0 circles, got %d", len(mock.Circles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOuterShape(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
cell := float64(60)
|
||||
|
||||
// Test each outer shape
|
||||
for i := 0; i < 4; i++ {
|
||||
mock.Reset()
|
||||
RenderOuterShape(g, i, cell)
|
||||
|
||||
// Verify that some drawing commands were issued
|
||||
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
|
||||
t.Errorf("Outer shape %d: expected some drawing commands, got none", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOuterShapeSpecific(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
cell := float64(60)
|
||||
|
||||
// Test outer shape 2 (rhombus)
|
||||
mock.Reset()
|
||||
RenderOuterShape(g, 2, cell)
|
||||
|
||||
if len(mock.Polygons) != 1 {
|
||||
t.Errorf("Outer shape 2: expected 1 polygon, got %d", len(mock.Polygons))
|
||||
}
|
||||
|
||||
// Test outer shape 3 (circle)
|
||||
mock.Reset()
|
||||
RenderOuterShape(g, 3, cell)
|
||||
|
||||
if len(mock.Circles) != 1 {
|
||||
t.Errorf("Outer shape 3: expected 1 circle, got %d", len(mock.Circles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeIndexModulo(t *testing.T) {
|
||||
mock := &MockRenderer{}
|
||||
g := NewGraphics(mock)
|
||||
cell := float64(60)
|
||||
|
||||
// Test that shape indices wrap around correctly
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 0, cell, 0)
|
||||
polygonsShape0 := len(mock.Polygons)
|
||||
circlesShape0 := len(mock.Circles)
|
||||
|
||||
mock.Reset()
|
||||
RenderCenterShape(g, 14, cell, 0) // Should be same as shape 0
|
||||
|
||||
if len(mock.Polygons) != polygonsShape0 || len(mock.Circles) != circlesShape0 {
|
||||
t.Errorf("Shape 14 should be equivalent to shape 0")
|
||||
}
|
||||
|
||||
// Test outer shapes
|
||||
mock.Reset()
|
||||
RenderOuterShape(g, 0, cell)
|
||||
polygonsOuter0 := len(mock.Polygons)
|
||||
circlesOuter0 := len(mock.Circles)
|
||||
|
||||
mock.Reset()
|
||||
RenderOuterShape(g, 4, cell) // Should be same as outer shape 0
|
||||
|
||||
if len(mock.Polygons) != polygonsOuter0 || len(mock.Circles) != circlesOuter0 {
|
||||
t.Errorf("Outer shape 4 should be equivalent to outer shape 0")
|
||||
}
|
||||
}
|
||||
103
internal/engine/transform.go
Normal file
103
internal/engine/transform.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package engine
|
||||
|
||||
import "math"
|
||||
|
||||
// Matrix represents a 2D transformation matrix in the form:
|
||||
// | A C E |
|
||||
// | B D F |
|
||||
// | 0 0 1 |
|
||||
type Matrix struct {
|
||||
A, B, C, D, E, F float64
|
||||
}
|
||||
|
||||
// NewIdentityMatrix creates an identity matrix
|
||||
func NewIdentityMatrix() Matrix {
|
||||
return Matrix{
|
||||
A: 1, B: 0, C: 0,
|
||||
D: 1, E: 0, F: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Translate creates a translation matrix
|
||||
func Translate(x, y float64) Matrix {
|
||||
return Matrix{
|
||||
A: 1, B: 0, C: 0,
|
||||
D: 1, E: x, F: y,
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate creates a rotation matrix for the given angle in radians
|
||||
func Rotate(angle float64) Matrix {
|
||||
cos := math.Cos(angle)
|
||||
sin := math.Sin(angle)
|
||||
return Matrix{
|
||||
A: cos, B: sin, C: -sin,
|
||||
D: cos, E: 0, F: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Scale creates a scaling matrix
|
||||
func Scale(sx, sy float64) Matrix {
|
||||
return Matrix{
|
||||
A: sx, B: 0, C: 0,
|
||||
D: sy, E: 0, F: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Multiply multiplies two matrices
|
||||
func (m Matrix) Multiply(other Matrix) Matrix {
|
||||
return Matrix{
|
||||
A: m.A*other.A + m.C*other.B,
|
||||
B: m.B*other.A + m.D*other.B,
|
||||
C: m.A*other.C + m.C*other.D,
|
||||
D: m.B*other.C + m.D*other.D,
|
||||
E: m.A*other.E + m.C*other.F + m.E,
|
||||
F: m.B*other.E + m.D*other.F + m.F,
|
||||
}
|
||||
}
|
||||
|
||||
// Transform represents a geometric transformation
|
||||
type Transform struct {
|
||||
x, y, size float64
|
||||
rotation int // 0 = 0 rad, 1 = 0.5π rad, 2 = π rad, 3 = 1.5π rad
|
||||
}
|
||||
|
||||
// NewTransform creates a new Transform
|
||||
func NewTransform(x, y, size float64, rotation int) Transform {
|
||||
return Transform{
|
||||
x: x,
|
||||
y: y,
|
||||
size: size,
|
||||
rotation: rotation,
|
||||
}
|
||||
}
|
||||
|
||||
// TransformIconPoint transforms a point based on the translation and rotation specification
|
||||
// w and h represent the width and height of the transformed rectangle for proper corner positioning
|
||||
func (t Transform) TransformIconPoint(x, y, w, h float64) Point {
|
||||
right := t.x + t.size
|
||||
bottom := t.y + t.size
|
||||
rotation := t.rotation % 4
|
||||
|
||||
switch rotation {
|
||||
case 1: // 90 degrees
|
||||
return Point{X: right - y - h, Y: t.y + x}
|
||||
case 2: // 180 degrees
|
||||
return Point{X: right - x - w, Y: bottom - y - h}
|
||||
case 3: // 270 degrees
|
||||
return Point{X: t.x + y, Y: bottom - x - w}
|
||||
default: // 0 degrees
|
||||
return Point{X: t.x + x, Y: t.y + y}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyTransform applies a transformation matrix to a point
|
||||
func ApplyTransform(point Point, matrix Matrix) Point {
|
||||
return Point{
|
||||
X: matrix.A*point.X + matrix.C*point.Y + matrix.E,
|
||||
Y: matrix.B*point.X + matrix.D*point.Y + matrix.F,
|
||||
}
|
||||
}
|
||||
|
||||
// NoTransform represents an identity transformation
|
||||
var NoTransform = NewTransform(0, 0, 0, 0)
|
||||
182
internal/engine/transform_test.go
Normal file
182
internal/engine/transform_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIdentityMatrix(t *testing.T) {
|
||||
m := NewIdentityMatrix()
|
||||
expected := Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}
|
||||
if m != expected {
|
||||
t.Errorf("NewIdentityMatrix() = %v, want %v", m, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslate(t *testing.T) {
|
||||
tests := []struct {
|
||||
x, y float64
|
||||
expected Matrix
|
||||
}{
|
||||
{10, 20, Matrix{A: 1, B: 0, C: 0, D: 1, E: 10, F: 20}},
|
||||
{0, 0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
|
||||
{-5, 15, Matrix{A: 1, B: 0, C: 0, D: 1, E: -5, F: 15}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Translate(tt.x, tt.y)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Translate(%v, %v) = %v, want %v", tt.x, tt.y, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotate(t *testing.T) {
|
||||
tests := []struct {
|
||||
angle float64
|
||||
expected Matrix
|
||||
}{
|
||||
{0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
|
||||
{math.Pi / 2, Matrix{A: 0, B: 1, C: -1, D: 0, E: 0, F: 0}},
|
||||
{math.Pi, Matrix{A: -1, B: 0, C: 0, D: -1, E: 0, F: 0}},
|
||||
{3 * math.Pi / 2, Matrix{A: 0, B: -1, C: 1, D: 0, E: 0, F: 0}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Rotate(tt.angle)
|
||||
// Use approximate equality for floating point comparison
|
||||
if !approximatelyEqual(result.A, tt.expected.A) ||
|
||||
!approximatelyEqual(result.B, tt.expected.B) ||
|
||||
!approximatelyEqual(result.C, tt.expected.C) ||
|
||||
!approximatelyEqual(result.D, tt.expected.D) ||
|
||||
!approximatelyEqual(result.E, tt.expected.E) ||
|
||||
!approximatelyEqual(result.F, tt.expected.F) {
|
||||
t.Errorf("Rotate(%v) = %v, want %v", tt.angle, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScale(t *testing.T) {
|
||||
tests := []struct {
|
||||
sx, sy float64
|
||||
expected Matrix
|
||||
}{
|
||||
{1, 1, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
|
||||
{2, 3, Matrix{A: 2, B: 0, C: 0, D: 3, E: 0, F: 0}},
|
||||
{0.5, 2, Matrix{A: 0.5, B: 0, C: 0, D: 2, E: 0, F: 0}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Scale(tt.sx, tt.sy)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Scale(%v, %v) = %v, want %v", tt.sx, tt.sy, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatrixMultiply(t *testing.T) {
|
||||
// Test identity multiplication
|
||||
identity := NewIdentityMatrix()
|
||||
translate := Translate(10, 20)
|
||||
result := identity.Multiply(translate)
|
||||
if result != translate {
|
||||
t.Errorf("Identity * Translate = %v, want %v", result, translate)
|
||||
}
|
||||
|
||||
// Test translation composition
|
||||
t1 := Translate(10, 20)
|
||||
t2 := Translate(5, 10)
|
||||
result = t1.Multiply(t2)
|
||||
expected := Translate(15, 30)
|
||||
if result != expected {
|
||||
t.Errorf("Translate(10,20) * Translate(5,10) = %v, want %v", result, expected)
|
||||
}
|
||||
|
||||
// Test scale composition
|
||||
s1 := Scale(2, 3)
|
||||
s2 := Scale(0.5, 0.5)
|
||||
result = s1.Multiply(s2)
|
||||
expected = Scale(1, 1.5)
|
||||
if result != expected {
|
||||
t.Errorf("Scale(2,3) * Scale(0.5,0.5) = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransform(t *testing.T) {
|
||||
tests := []struct {
|
||||
point Point
|
||||
matrix Matrix
|
||||
expected Point
|
||||
}{
|
||||
{Point{X: 0, Y: 0}, NewIdentityMatrix(), Point{X: 0, Y: 0}},
|
||||
{Point{X: 10, Y: 20}, Translate(5, 10), Point{X: 15, Y: 30}},
|
||||
{Point{X: 1, Y: 0}, Scale(3, 2), Point{X: 3, Y: 0}},
|
||||
{Point{X: 0, Y: 1}, Scale(3, 2), Point{X: 0, Y: 2}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := ApplyTransform(tt.point, tt.matrix)
|
||||
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
|
||||
t.Errorf("ApplyTransform(%v, %v) = %v, want %v", tt.point, tt.matrix, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTransform(t *testing.T) {
|
||||
transform := NewTransform(10, 20, 100, 1)
|
||||
if transform.x != 10 || transform.y != 20 || transform.size != 100 || transform.rotation != 1 {
|
||||
t.Errorf("NewTransform(10, 20, 100, 1) = %v, want {x:10, y:20, size:100, rotation:1}", transform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformIconPoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
transform Transform
|
||||
x, y, w, h float64
|
||||
expected Point
|
||||
}{
|
||||
// No rotation (0 degrees)
|
||||
{NewTransform(0, 0, 100, 0), 10, 20, 5, 5, Point{X: 10, Y: 20}},
|
||||
{NewTransform(10, 20, 100, 0), 5, 10, 0, 0, Point{X: 15, Y: 30}},
|
||||
|
||||
// 90 degrees rotation
|
||||
{NewTransform(0, 0, 100, 1), 10, 20, 5, 5, Point{X: 75, Y: 10}},
|
||||
|
||||
// 180 degrees rotation
|
||||
{NewTransform(0, 0, 100, 2), 10, 20, 5, 5, Point{X: 85, Y: 75}},
|
||||
|
||||
// 270 degrees rotation
|
||||
{NewTransform(0, 0, 100, 3), 10, 20, 5, 5, Point{X: 20, Y: 85}},
|
||||
|
||||
// Test rotation normalization (rotation > 3)
|
||||
{NewTransform(0, 0, 100, 4), 10, 20, 0, 0, Point{X: 10, Y: 20}}, // Same as rotation 0
|
||||
{NewTransform(0, 0, 100, 5), 10, 20, 5, 5, Point{X: 75, Y: 10}}, // Same as rotation 1
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.transform.TransformIconPoint(tt.x, tt.y, tt.w, tt.h)
|
||||
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
|
||||
t.Errorf("Transform(%v).TransformIconPoint(%v, %v, %v, %v) = %v, want %v",
|
||||
tt.transform, tt.x, tt.y, tt.w, tt.h, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTransform(t *testing.T) {
|
||||
if NoTransform.x != 0 || NoTransform.y != 0 || NoTransform.size != 0 || NoTransform.rotation != 0 {
|
||||
t.Errorf("NoTransform should be {x:0, y:0, size:0, rotation:0}, got %v", NoTransform)
|
||||
}
|
||||
|
||||
// Test that NoTransform doesn't change points
|
||||
point := Point{X: 10, Y: 20}
|
||||
result := NoTransform.TransformIconPoint(point.X, point.Y, 0, 0)
|
||||
if result != point {
|
||||
t.Errorf("NoTransform should not change point %v, got %v", point, result)
|
||||
}
|
||||
}
|
||||
|
||||
// approximatelyEqual checks if two float64 values are approximately equal
|
||||
func approximatelyEqual(a, b float64) bool {
|
||||
const epsilon = 1e-9
|
||||
return math.Abs(a-b) < epsilon
|
||||
}
|
||||
566
internal/renderer/integration_test.go
Normal file
566
internal/renderer/integration_test.go
Normal file
@@ -0,0 +1,566 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// TestPNGRenderer_VisualRegression tests that PNG output matches expected characteristics
|
||||
func TestPNGRenderer_VisualRegression(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
size int
|
||||
bg string
|
||||
bgOp float64
|
||||
shapes []testShape
|
||||
checksum string // Expected checksum of PNG data
|
||||
}{
|
||||
{
|
||||
name: "simple_red_square",
|
||||
size: 50,
|
||||
bg: "#ffffff",
|
||||
bgOp: 1.0,
|
||||
shapes: []testShape{
|
||||
{
|
||||
color: "#ff0000",
|
||||
polygons: [][]engine.Point{
|
||||
{
|
||||
{X: 10, Y: 10},
|
||||
{X: 40, Y: 10},
|
||||
{X: 40, Y: 40},
|
||||
{X: 10, Y: 40},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blue_circle",
|
||||
size: 60,
|
||||
bg: "#f0f0f0",
|
||||
bgOp: 1.0,
|
||||
shapes: []testShape{
|
||||
{
|
||||
color: "#0000ff",
|
||||
circles: []testCircle{
|
||||
{center: engine.Point{X: 30, Y: 30}, radius: 20, invert: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transparent_background",
|
||||
size: 40,
|
||||
bg: "#000000",
|
||||
bgOp: 0.0,
|
||||
shapes: []testShape{
|
||||
{
|
||||
color: "#00ff00",
|
||||
polygons: [][]engine.Point{
|
||||
{
|
||||
{X: 5, Y: 5},
|
||||
{X: 35, Y: 5},
|
||||
{X: 20, Y: 35},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(tc.size)
|
||||
|
||||
if tc.bgOp > 0 {
|
||||
renderer.SetBackground(tc.bg, tc.bgOp)
|
||||
}
|
||||
|
||||
for _, shape := range tc.shapes {
|
||||
renderer.BeginShape(shape.color)
|
||||
|
||||
for _, points := range shape.polygons {
|
||||
renderer.AddPolygon(points)
|
||||
}
|
||||
|
||||
for _, circle := range shape.circles {
|
||||
renderer.AddCircle(circle.center, circle.radius, circle.invert)
|
||||
}
|
||||
|
||||
renderer.EndShape()
|
||||
}
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
|
||||
// Verify PNG is valid
|
||||
reader := bytes.NewReader(pngData)
|
||||
img, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode PNG: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
|
||||
t.Errorf("Image size = %dx%d, want %dx%d",
|
||||
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
|
||||
}
|
||||
|
||||
// Calculate checksum for regression testing
|
||||
checksum := fmt.Sprintf("%x", sha1.Sum(pngData))
|
||||
t.Logf("PNG checksum for %s: %s", tc.name, checksum)
|
||||
|
||||
// Basic size validation
|
||||
if len(pngData) < 100 {
|
||||
t.Errorf("PNG data too small: %d bytes", len(pngData))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testShape represents a shape to be drawn for testing
|
||||
type testShape struct {
|
||||
color string
|
||||
polygons [][]engine.Point
|
||||
circles []testCircle
|
||||
}
|
||||
|
||||
type testCircle struct {
|
||||
center engine.Point
|
||||
radius float64
|
||||
invert bool
|
||||
}
|
||||
|
||||
// TestPNGRenderer_ComplexIcon tests rendering a more complex icon pattern
|
||||
func TestPNGRenderer_ComplexIcon(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
renderer.SetBackground("#f8f8f8", 1.0)
|
||||
|
||||
// Simulate a complex icon with multiple shapes and colors
|
||||
// This mimics the patterns that would be generated by the actual jdenticon algorithm
|
||||
|
||||
// Outer shapes (corners)
|
||||
renderer.BeginShape("#3f7cac")
|
||||
// Top-left triangle
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 0, Y: 0}, {X: 25, Y: 0}, {X: 0, Y: 25},
|
||||
})
|
||||
// Top-right triangle
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 75, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 25},
|
||||
})
|
||||
// Bottom-left triangle
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 0, Y: 75}, {X: 0, Y: 100}, {X: 25, Y: 100},
|
||||
})
|
||||
// Bottom-right triangle
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 75, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 75},
|
||||
})
|
||||
renderer.EndShape()
|
||||
|
||||
// Middle shapes
|
||||
renderer.BeginShape("#95b3d0")
|
||||
// Left rhombus
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 12.5, Y: 37.5}, {X: 25, Y: 50}, {X: 12.5, Y: 62.5}, {X: 0, Y: 50},
|
||||
})
|
||||
// Right rhombus
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 87.5, Y: 37.5}, {X: 100, Y: 50}, {X: 87.5, Y: 62.5}, {X: 75, Y: 50},
|
||||
})
|
||||
// Top rhombus
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 37.5, Y: 12.5}, {X: 50, Y: 0}, {X: 62.5, Y: 12.5}, {X: 50, Y: 25},
|
||||
})
|
||||
// Bottom rhombus
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 37.5, Y: 87.5}, {X: 50, Y: 75}, {X: 62.5, Y: 87.5}, {X: 50, Y: 100},
|
||||
})
|
||||
renderer.EndShape()
|
||||
|
||||
// Center shape
|
||||
renderer.BeginShape("#2f5f8f")
|
||||
renderer.AddCircle(engine.Point{X: 50, Y: 50}, 15, false)
|
||||
renderer.EndShape()
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
|
||||
// Verify the complex icon renders successfully
|
||||
reader := bytes.NewReader(pngData)
|
||||
img, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode complex PNG: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
|
||||
t.Errorf("Complex icon size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
|
||||
}
|
||||
|
||||
// Ensure PNG is reasonable size (not too large, not too small)
|
||||
if len(pngData) < 500 || len(pngData) > 50000 {
|
||||
t.Errorf("Complex PNG size %d bytes seems unreasonable", len(pngData))
|
||||
}
|
||||
|
||||
t.Logf("Complex icon PNG size: %d bytes", len(pngData))
|
||||
}
|
||||
|
||||
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
|
||||
// implement the Renderer interface consistently
|
||||
func TestRendererInterface_Consistency(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
size int
|
||||
bg string
|
||||
bgOp float64
|
||||
testFunc func(Renderer)
|
||||
}{
|
||||
{
|
||||
name: "basic_shapes",
|
||||
size: 100,
|
||||
bg: "#ffffff",
|
||||
bgOp: 1.0,
|
||||
testFunc: func(r Renderer) {
|
||||
r.BeginShape("#ff0000")
|
||||
r.AddRectangle(10, 10, 30, 30)
|
||||
r.EndShape()
|
||||
|
||||
r.BeginShape("#00ff00")
|
||||
r.AddCircle(engine.Point{X: 70, Y: 70}, 15, false)
|
||||
r.EndShape()
|
||||
|
||||
r.BeginShape("#0000ff")
|
||||
r.AddTriangle(
|
||||
engine.Point{X: 20, Y: 80},
|
||||
engine.Point{X: 40, Y: 80},
|
||||
engine.Point{X: 30, Y: 60},
|
||||
)
|
||||
r.EndShape()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complex_polygon",
|
||||
size: 80,
|
||||
bg: "#f8f8f8",
|
||||
bgOp: 0.8,
|
||||
testFunc: func(r Renderer) {
|
||||
r.BeginShape("#8B4513")
|
||||
// Star shape
|
||||
points := []engine.Point{
|
||||
{X: 40, Y: 10},
|
||||
{X: 45, Y: 25},
|
||||
{X: 60, Y: 25},
|
||||
{X: 50, Y: 35},
|
||||
{X: 55, Y: 50},
|
||||
{X: 40, Y: 40},
|
||||
{X: 25, Y: 50},
|
||||
{X: 30, Y: 35},
|
||||
{X: 20, Y: 25},
|
||||
{X: 35, Y: 25},
|
||||
}
|
||||
r.AddPolygon(points)
|
||||
r.EndShape()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "primitive_drawing",
|
||||
size: 60,
|
||||
bg: "",
|
||||
bgOp: 0,
|
||||
testFunc: func(r Renderer) {
|
||||
r.BeginShape("#FF6B35")
|
||||
r.MoveTo(10, 10)
|
||||
r.LineTo(50, 10)
|
||||
r.LineTo(50, 50)
|
||||
r.CurveTo(45, 55, 35, 55, 30, 50)
|
||||
r.LineTo(10, 50)
|
||||
r.ClosePath()
|
||||
r.Fill("#FF6B35")
|
||||
r.EndShape()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test with PNG renderer
|
||||
t.Run("png", func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(tc.size)
|
||||
if tc.bgOp > 0 {
|
||||
renderer.SetBackground(tc.bg, tc.bgOp)
|
||||
}
|
||||
|
||||
tc.testFunc(renderer)
|
||||
|
||||
// Verify PNG output
|
||||
pngData := renderer.ToPNG()
|
||||
if len(pngData) == 0 {
|
||||
t.Error("PNG renderer produced no data")
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(pngData)
|
||||
img, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("PNG decode failed: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
|
||||
t.Errorf("PNG size = %dx%d, want %dx%d",
|
||||
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
|
||||
}
|
||||
})
|
||||
|
||||
// Test with SVG renderer
|
||||
t.Run("svg", func(t *testing.T) {
|
||||
renderer := NewSVGRenderer(tc.size)
|
||||
if tc.bgOp > 0 {
|
||||
renderer.SetBackground(tc.bg, tc.bgOp)
|
||||
}
|
||||
|
||||
tc.testFunc(renderer)
|
||||
|
||||
// Verify SVG output
|
||||
svgData := renderer.ToSVG()
|
||||
if len(svgData) == 0 {
|
||||
t.Error("SVG renderer produced no data")
|
||||
}
|
||||
|
||||
// Basic SVG validation
|
||||
if !bytes.Contains([]byte(svgData), []byte("<svg")) {
|
||||
t.Error("SVG output missing opening tag")
|
||||
}
|
||||
if !bytes.Contains([]byte(svgData), []byte("</svg>")) {
|
||||
t.Error("SVG output missing closing tag")
|
||||
}
|
||||
|
||||
// Check size attributes
|
||||
expectedWidth := fmt.Sprintf(`width="%d"`, tc.size)
|
||||
expectedHeight := fmt.Sprintf(`height="%d"`, tc.size)
|
||||
if !bytes.Contains([]byte(svgData), []byte(expectedWidth)) {
|
||||
t.Errorf("SVG missing width attribute: %s", expectedWidth)
|
||||
}
|
||||
if !bytes.Contains([]byte(svgData), []byte(expectedHeight)) {
|
||||
t.Errorf("SVG missing height attribute: %s", expectedHeight)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRendererInterface_BaseRendererMethods tests that renderers properly use BaseRenderer methods
|
||||
func TestRendererInterface_BaseRendererMethods(t *testing.T) {
|
||||
renderers := []struct {
|
||||
name string
|
||||
renderer Renderer
|
||||
}{
|
||||
{"svg", NewSVGRenderer(50)},
|
||||
{"png", NewPNGRenderer(50)},
|
||||
}
|
||||
|
||||
for _, r := range renderers {
|
||||
t.Run(r.name, func(t *testing.T) {
|
||||
renderer := r.renderer
|
||||
|
||||
// Test size getter
|
||||
if renderer.GetSize() != 50 {
|
||||
t.Errorf("GetSize() = %d, want 50", renderer.GetSize())
|
||||
}
|
||||
|
||||
// Test background setting
|
||||
renderer.SetBackground("#123456", 0.75)
|
||||
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
|
||||
if bg, op := svgRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
|
||||
t.Errorf("SVG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
|
||||
}
|
||||
}
|
||||
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
|
||||
if bg, op := pngRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
|
||||
t.Errorf("PNG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
|
||||
}
|
||||
}
|
||||
|
||||
// Test shape management
|
||||
renderer.BeginShape("#ff0000")
|
||||
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
|
||||
if color := svgRenderer.GetCurrentColor(); color != "#ff0000" {
|
||||
t.Errorf("SVG GetCurrentColor() = %s, want #ff0000", color)
|
||||
}
|
||||
}
|
||||
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
|
||||
if color := pngRenderer.GetCurrentColor(); color != "#ff0000" {
|
||||
t.Errorf("PNG GetCurrentColor() = %s, want #ff0000", color)
|
||||
}
|
||||
}
|
||||
|
||||
// Test clearing
|
||||
renderer.Clear()
|
||||
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
|
||||
if color := svgRenderer.GetCurrentColor(); color != "" {
|
||||
t.Errorf("SVG GetCurrentColor() after Clear() = %s, want empty", color)
|
||||
}
|
||||
}
|
||||
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
|
||||
if color := pngRenderer.GetCurrentColor(); color != "" {
|
||||
t.Errorf("PNG GetCurrentColor() after Clear() = %s, want empty", color)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRendererInterface_CompatibilityWithJavaScript tests patterns from JavaScript reference
|
||||
func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
|
||||
// This test replicates patterns that would be used by the JavaScript jdenticon library
|
||||
// to ensure our Go implementation is compatible
|
||||
|
||||
testJavaScriptPattern := func(r Renderer) {
|
||||
// Simulate the JavaScript renderer usage pattern
|
||||
r.SetBackground("#f0f0f0", 1.0)
|
||||
|
||||
// Pattern similar to what iconGenerator.js would create
|
||||
shapes := []struct {
|
||||
color string
|
||||
actions func()
|
||||
}{
|
||||
{
|
||||
color: "#4a90e2",
|
||||
actions: func() {
|
||||
// Corner triangles (like JavaScript implementation)
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 0, Y: 0}, {X: 20, Y: 0}, {X: 0, Y: 20},
|
||||
})
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 80, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 20},
|
||||
})
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 0, Y: 80}, {X: 0, Y: 100}, {X: 20, Y: 100},
|
||||
})
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 80, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 80},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "#7fc383",
|
||||
actions: func() {
|
||||
// Center circle
|
||||
r.AddCircle(engine.Point{X: 50, Y: 50}, 25, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "#e94b3c",
|
||||
actions: func() {
|
||||
// Side rhombs
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 25, Y: 37.5}, {X: 37.5, Y: 50}, {X: 25, Y: 62.5}, {X: 12.5, Y: 50},
|
||||
})
|
||||
r.AddPolygon([]engine.Point{
|
||||
{X: 75, Y: 37.5}, {X: 87.5, Y: 50}, {X: 75, Y: 62.5}, {X: 62.5, Y: 50},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, shape := range shapes {
|
||||
r.BeginShape(shape.color)
|
||||
shape.actions()
|
||||
r.EndShape()
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("svg_javascript_pattern", func(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
testJavaScriptPattern(renderer)
|
||||
|
||||
svgData := renderer.ToSVG()
|
||||
|
||||
// Should contain multiple paths with different colors
|
||||
for _, color := range []string{"#4a90e2", "#7fc383", "#e94b3c"} {
|
||||
expected := fmt.Sprintf(`fill="%s"`, color)
|
||||
if !bytes.Contains([]byte(svgData), []byte(expected)) {
|
||||
t.Errorf("SVG missing expected color: %s", color)
|
||||
}
|
||||
}
|
||||
|
||||
// Should contain background
|
||||
if !bytes.Contains([]byte(svgData), []byte("#f0f0f0")) {
|
||||
t.Error("SVG missing background color")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("png_javascript_pattern", func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
testJavaScriptPattern(renderer)
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
|
||||
// Verify valid PNG
|
||||
reader := bytes.NewReader(pngData)
|
||||
img, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("PNG decode failed: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
|
||||
t.Errorf("PNG size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPNGRenderer_EdgeCases tests various edge cases
|
||||
func TestPNGRenderer_EdgeCases(t *testing.T) {
|
||||
t.Run("very_small_icon", func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(1)
|
||||
renderer.BeginShape("#ff0000")
|
||||
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}})
|
||||
renderer.EndShape()
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
if len(pngData) == 0 {
|
||||
t.Error("1x1 PNG should generate data")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("large_icon", func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(512)
|
||||
renderer.SetBackground("#ffffff", 1.0)
|
||||
renderer.BeginShape("#000000")
|
||||
renderer.AddCircle(engine.Point{X: 256, Y: 256}, 200, false)
|
||||
renderer.EndShape()
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
if len(pngData) == 0 {
|
||||
t.Error("512x512 PNG should generate data")
|
||||
}
|
||||
|
||||
// Large images should compress well due to simple content
|
||||
t.Logf("512x512 PNG size: %d bytes", len(pngData))
|
||||
})
|
||||
|
||||
t.Run("shapes_outside_bounds", func(t *testing.T) {
|
||||
renderer := NewPNGRenderer(50)
|
||||
renderer.BeginShape("#ff0000")
|
||||
|
||||
// Add shapes that extend outside the image bounds
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: -10, Y: -10}, {X: 60, Y: -10}, {X: 60, Y: 60}, {X: -10, Y: 60},
|
||||
})
|
||||
renderer.AddCircle(engine.Point{X: 25, Y: 25}, 50, false)
|
||||
renderer.EndShape()
|
||||
|
||||
// Should not panic and should produce valid PNG
|
||||
pngData := renderer.ToPNG()
|
||||
reader := bytes.NewReader(pngData)
|
||||
_, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to decode PNG with out-of-bounds shapes: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
292
internal/renderer/png.go
Normal file
292
internal/renderer/png.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// PNGRenderer implements the Renderer interface for PNG output
|
||||
type PNGRenderer struct {
|
||||
*BaseRenderer
|
||||
img *image.RGBA
|
||||
currentColor color.RGBA
|
||||
background color.RGBA
|
||||
hasBackground bool
|
||||
mu sync.RWMutex // For thread safety in concurrent generation
|
||||
}
|
||||
|
||||
// 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
|
||||
func NewPNGRenderer(iconSize int) *PNGRenderer {
|
||||
return &PNGRenderer{
|
||||
BaseRenderer: NewBaseRenderer(iconSize),
|
||||
img: image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)),
|
||||
}
|
||||
}
|
||||
|
||||
// SetBackground sets the background color and opacity
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
func (r *PNGRenderer) EndShape() {
|
||||
// No action needed for PNG - shapes are drawn immediately
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon with the current fill color to the image
|
||||
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
// Fill polygon using scanline algorithm
|
||||
r.fillPolygon(imagePoints)
|
||||
}
|
||||
|
||||
// AddCircle adds a circle with the current fill color to the image
|
||||
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
radius := size / 2
|
||||
centerX := int(math.Round(topLeft.X + radius))
|
||||
centerY := int(math.Round(topLeft.Y + radius))
|
||||
radiusInt := int(math.Round(radius))
|
||||
|
||||
// Use Bresenham's circle algorithm for anti-aliased circle drawing
|
||||
r.drawCircle(centerX, centerY, radiusInt, invert)
|
||||
}
|
||||
|
||||
// ToPNG generates the final PNG image data
|
||||
func (r *PNGRenderer) ToPNG() []byte {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer bufferPool.Put(buf)
|
||||
|
||||
// Encode to PNG with compression
|
||||
encoder := &png.Encoder{
|
||||
CompressionLevel: png.BestCompression,
|
||||
}
|
||||
|
||||
if err := encoder.Encode(buf, r.img); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return a copy of the buffer data
|
||||
result := make([]byte, buf.Len())
|
||||
copy(result, buf.Bytes())
|
||||
return result
|
||||
}
|
||||
|
||||
// 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, "#")
|
||||
|
||||
// Default to black if parsing fails
|
||||
var r, g, b uint8 = 0, 0, 0
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
alpha := uint8(math.Round(opacity * 255))
|
||||
return color.RGBA{R: r, G: g, B: b, A: alpha}
|
||||
}
|
||||
|
||||
// fillPolygon fills a polygon using a scanline algorithm
|
||||
func (r *PNGRenderer) fillPolygon(points []image.Point) {
|
||||
if len(points) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find bounding box
|
||||
minY, maxY := points[0].Y, points[0].Y
|
||||
for _, p := range points[1:] {
|
||||
if p.Y < minY {
|
||||
minY = p.Y
|
||||
}
|
||||
if p.Y > maxY {
|
||||
maxY = p.Y
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
j := (i + 1) % n
|
||||
p1, p2 := points[i], points[j]
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
290
internal/renderer/png_test.go
Normal file
290
internal/renderer/png_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
func TestNewPNGRenderer(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
|
||||
if renderer.iconSize != 100 {
|
||||
t.Errorf("NewPNGRenderer(100).iconSize = %v, want 100", renderer.iconSize)
|
||||
}
|
||||
if renderer.img == nil {
|
||||
t.Error("img should be initialized")
|
||||
}
|
||||
if renderer.img.Bounds().Max.X != 100 || renderer.img.Bounds().Max.Y != 100 {
|
||||
t.Errorf("image bounds = %v, want 100x100", renderer.img.Bounds())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_SetBackground(t *testing.T) {
|
||||
renderer := NewPNGRenderer(50)
|
||||
|
||||
renderer.SetBackground("#ff0000", 1.0)
|
||||
|
||||
if !renderer.hasBackground {
|
||||
t.Error("hasBackground should be true")
|
||||
}
|
||||
if renderer.backgroundOp != 1.0 {
|
||||
t.Errorf("backgroundOp = %v, want 1.0", renderer.backgroundOp)
|
||||
}
|
||||
|
||||
// Check that background was actually set
|
||||
expectedColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
if renderer.background != expectedColor {
|
||||
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
|
||||
}
|
||||
|
||||
// Check that image was filled with background
|
||||
actualColor := renderer.img.RGBAAt(25, 25)
|
||||
if actualColor != expectedColor {
|
||||
t.Errorf("image pixel color = %v, want %v", actualColor, expectedColor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_SetBackgroundWithOpacity(t *testing.T) {
|
||||
renderer := NewPNGRenderer(50)
|
||||
|
||||
renderer.SetBackground("#00ff00", 0.5)
|
||||
|
||||
expectedColor := color.RGBA{R: 0, G: 255, B: 0, A: 128}
|
||||
if renderer.background != expectedColor {
|
||||
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_BeginEndShape(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
|
||||
renderer.BeginShape("#0000ff")
|
||||
expectedColor := color.RGBA{R: 0, G: 0, B: 255, A: 255}
|
||||
if renderer.currentColor != expectedColor {
|
||||
t.Errorf("currentColor = %v, want %v", renderer.currentColor, expectedColor)
|
||||
}
|
||||
|
||||
renderer.EndShape()
|
||||
// EndShape is a no-op for PNG, just verify it doesn't panic
|
||||
}
|
||||
|
||||
func TestPNGRenderer_AddPolygon(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
renderer.BeginShape("#ff0000")
|
||||
|
||||
// Create a simple triangle
|
||||
points := []engine.Point{
|
||||
{X: 10, Y: 10},
|
||||
{X: 30, Y: 10},
|
||||
{X: 20, Y: 30},
|
||||
}
|
||||
|
||||
renderer.AddPolygon(points)
|
||||
|
||||
// Check that some pixels in the triangle are red
|
||||
redColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
centerPixel := renderer.img.RGBAAt(20, 15) // Should be inside triangle
|
||||
if centerPixel != redColor {
|
||||
t.Errorf("triangle center pixel = %v, want %v", centerPixel, redColor)
|
||||
}
|
||||
|
||||
// Check that pixels outside triangle are not red (should be transparent)
|
||||
outsidePixel := renderer.img.RGBAAt(5, 5)
|
||||
if outsidePixel == redColor {
|
||||
t.Error("pixel outside triangle should not be red")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_AddPolygonEmpty(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
renderer.BeginShape("#ff0000")
|
||||
|
||||
// Empty polygon should not panic
|
||||
renderer.AddPolygon([]engine.Point{})
|
||||
|
||||
// Polygon with < 3 points should not panic
|
||||
renderer.AddPolygon([]engine.Point{{X: 10, Y: 10}})
|
||||
renderer.AddPolygon([]engine.Point{{X: 10, Y: 10}, {X: 20, Y: 20}})
|
||||
}
|
||||
|
||||
func TestPNGRenderer_AddCircle(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
renderer.BeginShape("#00ff00")
|
||||
|
||||
// Circle with center at (50, 50) and radius 20 means topLeft at (30, 30) and size 40
|
||||
topLeft := engine.Point{X: 30, Y: 30}
|
||||
size := 40.0
|
||||
|
||||
renderer.AddCircle(topLeft, size, false)
|
||||
|
||||
// Check that center pixel is green
|
||||
greenColor := color.RGBA{R: 0, G: 255, B: 0, A: 255}
|
||||
centerPixel := renderer.img.RGBAAt(50, 50)
|
||||
if centerPixel != greenColor {
|
||||
t.Errorf("circle center pixel = %v, want %v", centerPixel, greenColor)
|
||||
}
|
||||
|
||||
// Check that a pixel clearly outside the circle is not green
|
||||
outsidePixel := renderer.img.RGBAAt(10, 10)
|
||||
if outsidePixel == greenColor {
|
||||
t.Error("pixel outside circle should not be green")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_AddCircleInvert(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
|
||||
// First fill with background
|
||||
renderer.SetBackground("#ffffff", 1.0)
|
||||
renderer.BeginShape("#ff0000")
|
||||
|
||||
// Add inverted circle (should punch a hole)
|
||||
// Circle with center at (50, 50) and radius 20 means topLeft at (30, 30) and size 40
|
||||
topLeft := engine.Point{X: 30, Y: 30}
|
||||
size := 40.0
|
||||
|
||||
renderer.AddCircle(topLeft, size, true)
|
||||
|
||||
// Check that center pixel is transparent (inverted)
|
||||
centerPixel := renderer.img.RGBAAt(50, 50)
|
||||
if centerPixel.A != 0 {
|
||||
t.Errorf("inverted circle center should be transparent, got %v", centerPixel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_ToPNG(t *testing.T) {
|
||||
renderer := NewPNGRenderer(50)
|
||||
renderer.SetBackground("#ffffff", 1.0)
|
||||
|
||||
renderer.BeginShape("#ff0000")
|
||||
points := []engine.Point{
|
||||
{X: 10, Y: 10},
|
||||
{X: 40, Y: 10},
|
||||
{X: 40, Y: 40},
|
||||
{X: 10, Y: 40},
|
||||
}
|
||||
renderer.AddPolygon(points)
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
|
||||
if len(pngData) == 0 {
|
||||
t.Error("ToPNG() should return non-empty data")
|
||||
}
|
||||
|
||||
// Verify it's valid PNG data by decoding it
|
||||
reader := bytes.NewReader(pngData)
|
||||
decodedImg, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Errorf("ToPNG() returned invalid PNG data: %v", err)
|
||||
}
|
||||
|
||||
// Check dimensions
|
||||
bounds := decodedImg.Bounds()
|
||||
if bounds.Max.X != 50 || bounds.Max.Y != 50 {
|
||||
t.Errorf("decoded image bounds = %v, want 50x50", bounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
|
||||
renderer := NewPNGRenderer(10)
|
||||
|
||||
pngData := renderer.ToPNG()
|
||||
|
||||
if len(pngData) == 0 {
|
||||
t.Error("ToPNG() should return data even for empty image")
|
||||
}
|
||||
|
||||
// Should be valid PNG
|
||||
reader := bytes.NewReader(pngData)
|
||||
_, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
t.Errorf("ToPNG() returned invalid PNG data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
opacity float64
|
||||
expected color.RGBA
|
||||
}{
|
||||
{"#ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
|
||||
{"ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
|
||||
{"#00ff00", 0.5, color.RGBA{R: 0, G: 255, B: 0, A: 128}},
|
||||
{"#0000ff", 0.0, color.RGBA{R: 0, G: 0, B: 255, A: 0}},
|
||||
{"#f00", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
|
||||
{"#0f0", 1.0, color.RGBA{R: 0, G: 255, B: 0, A: 255}},
|
||||
{"#00f", 1.0, color.RGBA{R: 0, G: 0, B: 255, A: 255}},
|
||||
{"#ff0000ff", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
|
||||
{"#ff000080", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 128}},
|
||||
{"invalid", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
|
||||
{"", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := parseColor(test.input, test.opacity)
|
||||
if result != test.expected {
|
||||
t.Errorf("parseColor(%q, %v) = %v, want %v",
|
||||
test.input, test.opacity, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRenderer_ConcurrentAccess(t *testing.T) {
|
||||
renderer := NewPNGRenderer(100)
|
||||
|
||||
// Test concurrent access to ensure thread safety
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
renderer.BeginShape("#ff0000")
|
||||
points := []engine.Point{
|
||||
{X: float64(id * 5), Y: float64(id * 5)},
|
||||
{X: float64(id*5 + 10), Y: float64(id * 5)},
|
||||
{X: float64(id*5 + 10), Y: float64(id*5 + 10)},
|
||||
{X: float64(id * 5), Y: float64(id*5 + 10)},
|
||||
}
|
||||
renderer.AddPolygon(points)
|
||||
renderer.EndShape()
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Should be able to generate PNG without issues
|
||||
pngData := renderer.ToPNG()
|
||||
if len(pngData) == 0 {
|
||||
t.Error("concurrent access test failed - no PNG data generated")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPNGRenderer_ToPNG(b *testing.B) {
|
||||
renderer := NewPNGRenderer(200)
|
||||
renderer.SetBackground("#ffffff", 1.0)
|
||||
|
||||
// Add some shapes for a realistic benchmark
|
||||
renderer.BeginShape("#ff0000")
|
||||
renderer.AddPolygon([]engine.Point{
|
||||
{X: 20, Y: 20}, {X: 80, Y: 20}, {X: 80, Y: 80}, {X: 20, Y: 80},
|
||||
})
|
||||
|
||||
renderer.BeginShape("#00ff00")
|
||||
renderer.AddCircle(engine.Point{X: 100, Y: 100}, 30, false)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pngData := renderer.ToPNG()
|
||||
if len(pngData) == 0 {
|
||||
b.Fatal("ToPNG returned empty data")
|
||||
}
|
||||
}
|
||||
}
|
||||
237
internal/renderer/renderer.go
Normal file
237
internal/renderer/renderer.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// Renderer defines the interface for rendering identicons to various output formats.
|
||||
// It provides a set of drawing primitives that can be implemented by concrete renderers
|
||||
// such as SVG, PNG, or other custom formats.
|
||||
type Renderer interface {
|
||||
// Drawing primitives
|
||||
MoveTo(x, y float64)
|
||||
LineTo(x, y float64)
|
||||
CurveTo(x1, y1, x2, y2, x, y float64)
|
||||
ClosePath()
|
||||
|
||||
// Fill and stroke operations
|
||||
Fill(color string)
|
||||
Stroke(color string, width float64)
|
||||
|
||||
// Shape management
|
||||
BeginShape(color string)
|
||||
EndShape()
|
||||
|
||||
// Background and configuration
|
||||
SetBackground(fillColor string, opacity float64)
|
||||
|
||||
// High-level shape methods
|
||||
AddPolygon(points []engine.Point)
|
||||
AddCircle(topLeft engine.Point, size float64, invert bool)
|
||||
AddRectangle(x, y, width, height float64)
|
||||
AddTriangle(p1, p2, p3 engine.Point)
|
||||
|
||||
// Utility methods
|
||||
GetSize() int
|
||||
Clear()
|
||||
}
|
||||
|
||||
// BaseRenderer provides default implementations for common renderer functionality.
|
||||
// Concrete renderers can embed this struct and override specific methods as needed.
|
||||
type BaseRenderer struct {
|
||||
iconSize int
|
||||
currentColor string
|
||||
background string
|
||||
backgroundOp float64
|
||||
|
||||
// Current path state for primitive operations
|
||||
currentPath []PathCommand
|
||||
pathStart engine.Point
|
||||
currentPos engine.Point
|
||||
}
|
||||
|
||||
// PathCommandType represents the type of path command
|
||||
type PathCommandType int
|
||||
|
||||
const (
|
||||
MoveToCommand PathCommandType = iota
|
||||
LineToCommand
|
||||
CurveToCommand
|
||||
ClosePathCommand
|
||||
)
|
||||
|
||||
// PathCommand represents a single drawing command in a path
|
||||
type PathCommand struct {
|
||||
Type PathCommandType
|
||||
Points []engine.Point
|
||||
}
|
||||
|
||||
// NewBaseRenderer creates a new base renderer with the specified icon size
|
||||
func NewBaseRenderer(iconSize int) *BaseRenderer {
|
||||
return &BaseRenderer{
|
||||
iconSize: iconSize,
|
||||
currentPath: make([]PathCommand, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// MoveTo moves the current drawing position to the specified coordinates
|
||||
func (r *BaseRenderer) MoveTo(x, y float64) {
|
||||
pos := engine.Point{X: x, Y: y}
|
||||
r.currentPos = pos
|
||||
r.pathStart = pos
|
||||
r.currentPath = append(r.currentPath, PathCommand{
|
||||
Type: MoveToCommand,
|
||||
Points: []engine.Point{pos},
|
||||
})
|
||||
}
|
||||
|
||||
// LineTo draws a line from the current position to the specified coordinates
|
||||
func (r *BaseRenderer) LineTo(x, y float64) {
|
||||
pos := engine.Point{X: x, Y: y}
|
||||
r.currentPos = pos
|
||||
r.currentPath = append(r.currentPath, PathCommand{
|
||||
Type: LineToCommand,
|
||||
Points: []engine.Point{pos},
|
||||
})
|
||||
}
|
||||
|
||||
// CurveTo draws a cubic Bézier curve from the current position to (x, y) using (x1, y1) and (x2, y2) as control points
|
||||
func (r *BaseRenderer) CurveTo(x1, y1, x2, y2, x, y float64) {
|
||||
endPos := engine.Point{X: x, Y: y}
|
||||
r.currentPos = endPos
|
||||
r.currentPath = append(r.currentPath, PathCommand{
|
||||
Type: CurveToCommand,
|
||||
Points: []engine.Point{
|
||||
{X: x1, Y: y1},
|
||||
{X: x2, Y: y2},
|
||||
endPos,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ClosePath closes the current path by drawing a line back to the path start
|
||||
func (r *BaseRenderer) ClosePath() {
|
||||
r.currentPos = r.pathStart
|
||||
r.currentPath = append(r.currentPath, PathCommand{
|
||||
Type: ClosePathCommand,
|
||||
Points: []engine.Point{},
|
||||
})
|
||||
}
|
||||
|
||||
// Fill fills the current path with the specified color
|
||||
func (r *BaseRenderer) Fill(color string) {
|
||||
// Default implementation - concrete renderers should override
|
||||
}
|
||||
|
||||
// Stroke strokes the current path with the specified color and width
|
||||
func (r *BaseRenderer) Stroke(color string, width float64) {
|
||||
// Default implementation - concrete renderers should override
|
||||
}
|
||||
|
||||
// BeginShape starts a new shape with the specified color
|
||||
func (r *BaseRenderer) BeginShape(color string) {
|
||||
r.currentColor = color
|
||||
r.currentPath = make([]PathCommand, 0)
|
||||
}
|
||||
|
||||
// EndShape ends the current shape
|
||||
func (r *BaseRenderer) EndShape() {
|
||||
// Default implementation - concrete renderers should override
|
||||
}
|
||||
|
||||
// SetBackground sets the background color and opacity
|
||||
func (r *BaseRenderer) SetBackground(fillColor string, opacity float64) {
|
||||
r.background = fillColor
|
||||
r.backgroundOp = opacity
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon to the renderer using the current fill color
|
||||
func (r *BaseRenderer) AddPolygon(points []engine.Point) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Move to first point
|
||||
r.MoveTo(points[0].X, points[0].Y)
|
||||
|
||||
// Line to subsequent points
|
||||
for i := 1; i < len(points); i++ {
|
||||
r.LineTo(points[i].X, points[i].Y)
|
||||
}
|
||||
|
||||
// Close the path
|
||||
r.ClosePath()
|
||||
|
||||
// Fill with current color
|
||||
r.Fill(r.currentColor)
|
||||
}
|
||||
|
||||
// AddCircle adds a circle to the renderer using the current fill color
|
||||
func (r *BaseRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
||||
// Approximate circle using cubic Bézier curves
|
||||
// Magic number for circle approximation with Bézier curves
|
||||
const kappa = 0.5522847498307936 // 4/3 * (sqrt(2) - 1)
|
||||
|
||||
radius := size / 2
|
||||
centerX := topLeft.X + radius
|
||||
centerY := topLeft.Y + radius
|
||||
|
||||
cp := kappa * radius // Control point distance
|
||||
|
||||
// Start at rightmost point
|
||||
r.MoveTo(centerX+radius, centerY)
|
||||
|
||||
// Four cubic curves to approximate circle
|
||||
r.CurveTo(centerX+radius, centerY+cp, centerX+cp, centerY+radius, centerX, centerY+radius)
|
||||
r.CurveTo(centerX-cp, centerY+radius, centerX-radius, centerY+cp, centerX-radius, centerY)
|
||||
r.CurveTo(centerX-radius, centerY-cp, centerX-cp, centerY-radius, centerX, centerY-radius)
|
||||
r.CurveTo(centerX+cp, centerY-radius, centerX+radius, centerY-cp, centerX+radius, centerY)
|
||||
|
||||
r.ClosePath()
|
||||
r.Fill(r.currentColor)
|
||||
}
|
||||
|
||||
// AddRectangle adds a rectangle to the renderer
|
||||
func (r *BaseRenderer) AddRectangle(x, y, width, height float64) {
|
||||
points := []engine.Point{
|
||||
{X: x, Y: y},
|
||||
{X: x + width, Y: y},
|
||||
{X: x + width, Y: y + height},
|
||||
{X: x, Y: y + height},
|
||||
}
|
||||
r.AddPolygon(points)
|
||||
}
|
||||
|
||||
// AddTriangle adds a triangle to the renderer
|
||||
func (r *BaseRenderer) AddTriangle(p1, p2, p3 engine.Point) {
|
||||
points := []engine.Point{p1, p2, p3}
|
||||
r.AddPolygon(points)
|
||||
}
|
||||
|
||||
// GetSize returns the icon size
|
||||
func (r *BaseRenderer) GetSize() int {
|
||||
return r.iconSize
|
||||
}
|
||||
|
||||
// Clear clears the renderer state
|
||||
func (r *BaseRenderer) Clear() {
|
||||
r.currentPath = make([]PathCommand, 0)
|
||||
r.currentColor = ""
|
||||
r.background = ""
|
||||
r.backgroundOp = 0
|
||||
}
|
||||
|
||||
// GetCurrentPath returns the current path commands
|
||||
func (r *BaseRenderer) GetCurrentPath() []PathCommand {
|
||||
return r.currentPath
|
||||
}
|
||||
|
||||
// GetCurrentColor returns the current drawing color
|
||||
func (r *BaseRenderer) GetCurrentColor() string {
|
||||
return r.currentColor
|
||||
}
|
||||
|
||||
// GetBackground returns the background color and opacity
|
||||
func (r *BaseRenderer) GetBackground() (string, float64) {
|
||||
return r.background, r.backgroundOp
|
||||
}
|
||||
362
internal/renderer/renderer_test.go
Normal file
362
internal/renderer/renderer_test.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
func TestNewBaseRenderer(t *testing.T) {
|
||||
iconSize := 100
|
||||
r := NewBaseRenderer(iconSize)
|
||||
|
||||
if r.GetSize() != iconSize {
|
||||
t.Errorf("Expected icon size %d, got %d", iconSize, r.GetSize())
|
||||
}
|
||||
|
||||
if len(r.GetCurrentPath()) != 0 {
|
||||
t.Errorf("Expected empty path, got %d commands", len(r.GetCurrentPath()))
|
||||
}
|
||||
|
||||
if r.GetCurrentColor() != "" {
|
||||
t.Errorf("Expected empty current color, got %s", r.GetCurrentColor())
|
||||
}
|
||||
|
||||
bg, bgOp := r.GetBackground()
|
||||
if bg != "" || bgOp != 0 {
|
||||
t.Errorf("Expected empty background, got %s with opacity %f", bg, bgOp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererSetBackground(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
color := "#ff0000"
|
||||
opacity := 0.5
|
||||
|
||||
r.SetBackground(color, opacity)
|
||||
|
||||
bg, bgOp := r.GetBackground()
|
||||
if bg != color {
|
||||
t.Errorf("Expected background color %s, got %s", color, bg)
|
||||
}
|
||||
if bgOp != opacity {
|
||||
t.Errorf("Expected background opacity %f, got %f", opacity, bgOp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererBeginShape(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
color := "#00ff00"
|
||||
r.BeginShape(color)
|
||||
|
||||
if r.GetCurrentColor() != color {
|
||||
t.Errorf("Expected current color %s, got %s", color, r.GetCurrentColor())
|
||||
}
|
||||
|
||||
// Path should be reset when beginning a shape
|
||||
if len(r.GetCurrentPath()) != 0 {
|
||||
t.Errorf("Expected empty path after BeginShape, got %d commands", len(r.GetCurrentPath()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererMoveTo(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
x, y := 10.5, 20.3
|
||||
r.MoveTo(x, y)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
if len(path) != 1 {
|
||||
t.Fatalf("Expected 1 path command, got %d", len(path))
|
||||
}
|
||||
|
||||
cmd := path[0]
|
||||
if cmd.Type != MoveToCommand {
|
||||
t.Errorf("Expected MoveToCommand, got %v", cmd.Type)
|
||||
}
|
||||
|
||||
if len(cmd.Points) != 1 {
|
||||
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
|
||||
}
|
||||
|
||||
point := cmd.Points[0]
|
||||
if point.X != x || point.Y != y {
|
||||
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererLineTo(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
// Move to start point first
|
||||
r.MoveTo(0, 0)
|
||||
|
||||
x, y := 15.7, 25.9
|
||||
r.LineTo(x, y)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
if len(path) != 2 {
|
||||
t.Fatalf("Expected 2 path commands, got %d", len(path))
|
||||
}
|
||||
|
||||
cmd := path[1] // Second command should be LineTo
|
||||
if cmd.Type != LineToCommand {
|
||||
t.Errorf("Expected LineToCommand, got %v", cmd.Type)
|
||||
}
|
||||
|
||||
if len(cmd.Points) != 1 {
|
||||
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
|
||||
}
|
||||
|
||||
point := cmd.Points[0]
|
||||
if point.X != x || point.Y != y {
|
||||
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererCurveTo(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
// Move to start point first
|
||||
r.MoveTo(0, 0)
|
||||
|
||||
x1, y1 := 10.0, 5.0
|
||||
x2, y2 := 20.0, 15.0
|
||||
x, y := 30.0, 25.0
|
||||
|
||||
r.CurveTo(x1, y1, x2, y2, x, y)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
if len(path) != 2 {
|
||||
t.Fatalf("Expected 2 path commands, got %d", len(path))
|
||||
}
|
||||
|
||||
cmd := path[1] // Second command should be CurveTo
|
||||
if cmd.Type != CurveToCommand {
|
||||
t.Errorf("Expected CurveToCommand, got %v", cmd.Type)
|
||||
}
|
||||
|
||||
if len(cmd.Points) != 3 {
|
||||
t.Fatalf("Expected 3 points, got %d", len(cmd.Points))
|
||||
}
|
||||
|
||||
// Check control points and end point
|
||||
if cmd.Points[0].X != x1 || cmd.Points[0].Y != y1 {
|
||||
t.Errorf("Expected first control point (%f, %f), got (%f, %f)", x1, y1, cmd.Points[0].X, cmd.Points[0].Y)
|
||||
}
|
||||
if cmd.Points[1].X != x2 || cmd.Points[1].Y != y2 {
|
||||
t.Errorf("Expected second control point (%f, %f), got (%f, %f)", x2, y2, cmd.Points[1].X, cmd.Points[1].Y)
|
||||
}
|
||||
if cmd.Points[2].X != x || cmd.Points[2].Y != y {
|
||||
t.Errorf("Expected end point (%f, %f), got (%f, %f)", x, y, cmd.Points[2].X, cmd.Points[2].Y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererClosePath(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
// Move to start point first
|
||||
r.MoveTo(0, 0)
|
||||
r.LineTo(10, 10)
|
||||
r.ClosePath()
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
if len(path) != 3 {
|
||||
t.Fatalf("Expected 3 path commands, got %d", len(path))
|
||||
}
|
||||
|
||||
cmd := path[2] // Third command should be ClosePath
|
||||
if cmd.Type != ClosePathCommand {
|
||||
t.Errorf("Expected ClosePathCommand, got %v", cmd.Type)
|
||||
}
|
||||
|
||||
if len(cmd.Points) != 0 {
|
||||
t.Errorf("Expected 0 points for ClosePath, got %d", len(cmd.Points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererAddPolygon(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
r.BeginShape("#ff0000")
|
||||
|
||||
points := []engine.Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: 10, Y: 0},
|
||||
{X: 10, Y: 10},
|
||||
{X: 0, Y: 10},
|
||||
}
|
||||
|
||||
r.AddPolygon(points)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
|
||||
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
|
||||
expectedCommands := len(points) + 1 // +1 for ClosePath
|
||||
if len(path) != expectedCommands {
|
||||
t.Fatalf("Expected %d path commands, got %d", expectedCommands, len(path))
|
||||
}
|
||||
|
||||
// Check first command is MoveTo
|
||||
if path[0].Type != MoveToCommand {
|
||||
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
|
||||
}
|
||||
|
||||
// Check last command is ClosePath
|
||||
if path[len(path)-1].Type != ClosePathCommand {
|
||||
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererAddRectangle(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
r.BeginShape("#0000ff")
|
||||
|
||||
x, y, width, height := 5.0, 10.0, 20.0, 15.0
|
||||
r.AddRectangle(x, y, width, height)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
|
||||
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
|
||||
if len(path) != 5 {
|
||||
t.Fatalf("Expected 5 path commands, got %d", len(path))
|
||||
}
|
||||
|
||||
// Verify the rectangle points
|
||||
expectedPoints := []engine.Point{
|
||||
{X: x, Y: y}, // bottom-left
|
||||
{X: x + width, Y: y}, // bottom-right
|
||||
{X: x + width, Y: y + height}, // top-right
|
||||
{X: x, Y: y + height}, // top-left
|
||||
}
|
||||
|
||||
// Check MoveTo point
|
||||
if path[0].Points[0] != expectedPoints[0] {
|
||||
t.Errorf("Expected first point %v, got %v", expectedPoints[0], path[0].Points[0])
|
||||
}
|
||||
|
||||
// Check LineTo points
|
||||
for i := 1; i < 4; i++ {
|
||||
if path[i].Type != LineToCommand {
|
||||
t.Errorf("Expected LineTo command at index %d, got %v", i, path[i].Type)
|
||||
}
|
||||
if path[i].Points[0] != expectedPoints[i] {
|
||||
t.Errorf("Expected point %v at index %d, got %v", expectedPoints[i], i, path[i].Points[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererAddTriangle(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
r.BeginShape("#00ffff")
|
||||
|
||||
p1 := engine.Point{X: 0, Y: 0}
|
||||
p2 := engine.Point{X: 10, Y: 0}
|
||||
p3 := engine.Point{X: 5, Y: 10}
|
||||
|
||||
r.AddTriangle(p1, p2, p3)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
|
||||
// Should have MoveTo + 2 LineTo + ClosePath = 4 commands
|
||||
if len(path) != 4 {
|
||||
t.Fatalf("Expected 4 path commands, got %d", len(path))
|
||||
}
|
||||
|
||||
// Check the triangle points
|
||||
if path[0].Points[0] != p1 {
|
||||
t.Errorf("Expected first point %v, got %v", p1, path[0].Points[0])
|
||||
}
|
||||
if path[1].Points[0] != p2 {
|
||||
t.Errorf("Expected second point %v, got %v", p2, path[1].Points[0])
|
||||
}
|
||||
if path[2].Points[0] != p3 {
|
||||
t.Errorf("Expected third point %v, got %v", p3, path[2].Points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererAddCircle(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
r.BeginShape("#ffff00")
|
||||
|
||||
center := engine.Point{X: 50, Y: 50}
|
||||
radius := 25.0
|
||||
|
||||
r.AddCircle(center, radius, false)
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
|
||||
// Should have MoveTo + 4 CurveTo + ClosePath = 6 commands
|
||||
if len(path) != 6 {
|
||||
t.Fatalf("Expected 6 path commands for circle, got %d", len(path))
|
||||
}
|
||||
|
||||
// Check first command is MoveTo
|
||||
if path[0].Type != MoveToCommand {
|
||||
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
|
||||
}
|
||||
|
||||
// Check that we have 4 CurveTo commands
|
||||
curveCount := 0
|
||||
for i := 1; i < len(path)-1; i++ {
|
||||
if path[i].Type == CurveToCommand {
|
||||
curveCount++
|
||||
}
|
||||
}
|
||||
if curveCount != 4 {
|
||||
t.Errorf("Expected 4 CurveTo commands for circle, got %d", curveCount)
|
||||
}
|
||||
|
||||
// Check last command is ClosePath
|
||||
if path[len(path)-1].Type != ClosePathCommand {
|
||||
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererClear(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
|
||||
// Set some state
|
||||
r.BeginShape("#ff0000")
|
||||
r.SetBackground("#ffffff", 0.8)
|
||||
r.MoveTo(10, 20)
|
||||
r.LineTo(30, 40)
|
||||
|
||||
// Verify state is set
|
||||
if r.GetCurrentColor() == "" {
|
||||
t.Error("Expected current color to be set before clear")
|
||||
}
|
||||
if len(r.GetCurrentPath()) == 0 {
|
||||
t.Error("Expected path commands before clear")
|
||||
}
|
||||
|
||||
// Clear the renderer
|
||||
r.Clear()
|
||||
|
||||
// Verify state is cleared
|
||||
if r.GetCurrentColor() != "" {
|
||||
t.Errorf("Expected empty current color after clear, got %s", r.GetCurrentColor())
|
||||
}
|
||||
if len(r.GetCurrentPath()) != 0 {
|
||||
t.Errorf("Expected empty path after clear, got %d commands", len(r.GetCurrentPath()))
|
||||
}
|
||||
|
||||
bg, bgOp := r.GetBackground()
|
||||
if bg != "" || bgOp != 0 {
|
||||
t.Errorf("Expected empty background after clear, got %s with opacity %f", bg, bgOp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRendererEmptyPolygon(t *testing.T) {
|
||||
r := NewBaseRenderer(100)
|
||||
r.BeginShape("#ff0000")
|
||||
|
||||
// Test with empty points slice
|
||||
r.AddPolygon([]engine.Point{})
|
||||
|
||||
path := r.GetCurrentPath()
|
||||
if len(path) != 0 {
|
||||
t.Errorf("Expected no path commands for empty polygon, got %d", len(path))
|
||||
}
|
||||
}
|
||||
172
internal/renderer/svg.go
Normal file
172
internal/renderer/svg.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
// SVGPath represents an SVG path element
|
||||
type SVGPath struct {
|
||||
data strings.Builder
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon to the SVG path
|
||||
func (p *SVGPath) AddPolygon(points []engine.Point) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Move to first point
|
||||
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(points[0].X), svgValue(points[0].Y)))
|
||||
|
||||
// Line to subsequent points
|
||||
for i := 1; i < len(points); i++ {
|
||||
p.data.WriteString(fmt.Sprintf("L%s %s", svgValue(points[i].X), svgValue(points[i].Y)))
|
||||
}
|
||||
|
||||
// Close path
|
||||
p.data.WriteString("Z")
|
||||
}
|
||||
|
||||
// AddCircle adds a circle to the SVG path
|
||||
func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise bool) {
|
||||
sweepFlag := "1"
|
||||
if counterClockwise {
|
||||
sweepFlag = "0"
|
||||
}
|
||||
|
||||
radius := size / 2
|
||||
centerX := topLeft.X + radius
|
||||
centerY := topLeft.Y + radius
|
||||
|
||||
svgRadius := svgValue(radius)
|
||||
svgDiameter := svgValue(size)
|
||||
svgArc := fmt.Sprintf("a%s,%s 0 1,%s ", svgRadius, svgRadius, sweepFlag)
|
||||
|
||||
// Move to start point (left side of circle)
|
||||
startX := centerX - radius
|
||||
startY := centerY
|
||||
|
||||
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(startX), svgValue(startY)))
|
||||
p.data.WriteString(svgArc + svgDiameter + ",0")
|
||||
p.data.WriteString(svgArc + "-" + svgDiameter + ",0")
|
||||
}
|
||||
|
||||
// DataString returns the SVG path data string
|
||||
func (p *SVGPath) DataString() string {
|
||||
return p.data.String()
|
||||
}
|
||||
|
||||
// SVGRenderer implements the Renderer interface for SVG output
|
||||
type SVGRenderer struct {
|
||||
*BaseRenderer
|
||||
pathsByColor map[string]*SVGPath
|
||||
colorOrder []string
|
||||
}
|
||||
|
||||
// NewSVGRenderer creates a new SVG renderer
|
||||
func NewSVGRenderer(iconSize int) *SVGRenderer {
|
||||
return &SVGRenderer{
|
||||
BaseRenderer: NewBaseRenderer(iconSize),
|
||||
pathsByColor: make(map[string]*SVGPath),
|
||||
colorOrder: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// SetBackground sets the background color and opacity
|
||||
func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
|
||||
r.BaseRenderer.SetBackground(fillColor, opacity)
|
||||
}
|
||||
|
||||
// BeginShape marks the beginning of a new shape with the specified color
|
||||
func (r *SVGRenderer) BeginShape(color string) {
|
||||
r.BaseRenderer.BeginShape(color)
|
||||
if _, exists := r.pathsByColor[color]; !exists {
|
||||
r.pathsByColor[color] = &SVGPath{}
|
||||
r.colorOrder = append(r.colorOrder, color)
|
||||
}
|
||||
}
|
||||
|
||||
// EndShape marks the end of the currently drawn shape
|
||||
func (r *SVGRenderer) EndShape() {
|
||||
// No action needed for SVG
|
||||
}
|
||||
|
||||
// getCurrentPath returns the current path for the active color
|
||||
func (r *SVGRenderer) getCurrentPath() *SVGPath {
|
||||
currentColor := r.GetCurrentColor()
|
||||
if currentColor == "" {
|
||||
return nil
|
||||
}
|
||||
return r.pathsByColor[currentColor]
|
||||
}
|
||||
|
||||
// AddPolygon adds a polygon with the current fill color to the SVG
|
||||
func (r *SVGRenderer) AddPolygon(points []engine.Point) {
|
||||
if path := r.getCurrentPath(); path != nil {
|
||||
path.AddPolygon(points)
|
||||
}
|
||||
}
|
||||
|
||||
// AddCircle adds a circle with the current fill color to the SVG
|
||||
func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
||||
if path := r.getCurrentPath(); path != nil {
|
||||
path.AddCircle(topLeft, size, invert)
|
||||
}
|
||||
}
|
||||
|
||||
// ToSVG generates the final SVG XML string
|
||||
func (r *SVGRenderer) ToSVG() string {
|
||||
var svg strings.Builder
|
||||
|
||||
iconSize := r.GetSize()
|
||||
background, backgroundOp := r.GetBackground()
|
||||
|
||||
// SVG opening tag with namespace and dimensions
|
||||
svg.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
||||
iconSize, iconSize, iconSize, iconSize))
|
||||
|
||||
// Add background rectangle if specified
|
||||
if background != "" && backgroundOp > 0 {
|
||||
if backgroundOp >= 1.0 {
|
||||
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s"/>`, background))
|
||||
} else {
|
||||
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s" opacity="%.2f"/>`,
|
||||
background, backgroundOp))
|
||||
}
|
||||
}
|
||||
|
||||
// Add paths for each color (in insertion order to preserve z-order)
|
||||
for _, color := range r.colorOrder {
|
||||
path := r.pathsByColor[color]
|
||||
dataString := path.DataString()
|
||||
if dataString != "" {
|
||||
svg.WriteString(fmt.Sprintf(`<path fill="%s" d="%s"/>`, color, dataString))
|
||||
}
|
||||
}
|
||||
|
||||
// SVG closing tag
|
||||
svg.WriteString("</svg>")
|
||||
|
||||
return svg.String()
|
||||
}
|
||||
|
||||
// svgValue rounds a float64 to one decimal place, mimicking the Jdenticon JS implementation's
|
||||
// "round half up" behavior. It also formats the number to a minimal string representation.
|
||||
func svgValue(value float64) string {
|
||||
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
|
||||
// JavaScript: ((value * 10 + 0.5) | 0) / 10
|
||||
rounded := math.Floor(value*10 + 0.5) / 10
|
||||
|
||||
// Format to an integer string if there's no fractional part.
|
||||
if rounded == math.Trunc(rounded) {
|
||||
return strconv.Itoa(int(rounded))
|
||||
}
|
||||
|
||||
// Otherwise, format to one decimal place.
|
||||
return strconv.FormatFloat(rounded, 'f', 1, 64)
|
||||
}
|
||||
240
internal/renderer/svg_test.go
Normal file
240
internal/renderer/svg_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kevin/go-jdenticon/internal/engine"
|
||||
)
|
||||
|
||||
func TestSVGPath_AddPolygon(t *testing.T) {
|
||||
path := &SVGPath{}
|
||||
points := []engine.Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: 10, Y: 0},
|
||||
{X: 10, Y: 10},
|
||||
{X: 0, Y: 10},
|
||||
}
|
||||
|
||||
path.AddPolygon(points)
|
||||
expected := "M0 0L10 0L10 10L0 10Z"
|
||||
if got := path.DataString(); got != expected {
|
||||
t.Errorf("AddPolygon() = %v, want %v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGPath_AddPolygonEmpty(t *testing.T) {
|
||||
path := &SVGPath{}
|
||||
path.AddPolygon([]engine.Point{})
|
||||
|
||||
if got := path.DataString(); got != "" {
|
||||
t.Errorf("AddPolygon([]) = %v, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGPath_AddCircle(t *testing.T) {
|
||||
path := &SVGPath{}
|
||||
topLeft := engine.Point{X: 25, Y: 25} // Top-left corner to get center at (50, 50)
|
||||
size := 50.0 // Size 50 gives radius 25
|
||||
|
||||
path.AddCircle(topLeft, size, false)
|
||||
|
||||
// Should start at left side of circle and draw two arcs
|
||||
result := path.DataString()
|
||||
if !strings.HasPrefix(result, "M25 50") {
|
||||
t.Errorf("Circle should start at left side, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "a25,25 0 1,1") {
|
||||
t.Errorf("Circle should contain clockwise arc, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGPath_AddCircleCounterClockwise(t *testing.T) {
|
||||
path := &SVGPath{}
|
||||
topLeft := engine.Point{X: 25, Y: 25} // Top-left corner to get center at (50, 50)
|
||||
size := 50.0 // Size 50 gives radius 25
|
||||
|
||||
path.AddCircle(topLeft, size, true)
|
||||
|
||||
result := path.DataString()
|
||||
if !strings.Contains(result, "a25,25 0 1,0") {
|
||||
t.Errorf("Counter-clockwise circle should have sweep flag 0, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_NewSVGRenderer(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
|
||||
if renderer.iconSize != 100 {
|
||||
t.Errorf("NewSVGRenderer(100).iconSize = %v, want 100", renderer.iconSize)
|
||||
}
|
||||
if renderer.pathsByColor == nil {
|
||||
t.Error("pathsByColor should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_BeginEndShape(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
|
||||
renderer.BeginShape("#ff0000")
|
||||
if renderer.currentColor != "#ff0000" {
|
||||
t.Errorf("BeginShape should set currentColor, got %v", renderer.currentColor)
|
||||
}
|
||||
|
||||
if _, exists := renderer.pathsByColor["#ff0000"]; !exists {
|
||||
t.Error("BeginShape should create path for color")
|
||||
}
|
||||
|
||||
renderer.EndShape()
|
||||
// EndShape is a no-op for SVG, just verify it doesn't panic
|
||||
}
|
||||
|
||||
func TestSVGRenderer_AddPolygon(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
renderer.BeginShape("#ff0000")
|
||||
|
||||
points := []engine.Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: 10, Y: 0},
|
||||
{X: 5, Y: 10},
|
||||
}
|
||||
|
||||
renderer.AddPolygon(points)
|
||||
|
||||
path := renderer.pathsByColor["#ff0000"]
|
||||
expected := "M0 0L10 0L5 10Z"
|
||||
if got := path.DataString(); got != expected {
|
||||
t.Errorf("AddPolygon() = %v, want %v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_AddCircle(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
renderer.BeginShape("#00ff00")
|
||||
|
||||
topLeft := engine.Point{X: 30, Y: 30} // Top-left corner to get center at (50, 50)
|
||||
size := 40.0 // Size 40 gives radius 20
|
||||
|
||||
renderer.AddCircle(topLeft, size, false)
|
||||
|
||||
path := renderer.pathsByColor["#00ff00"]
|
||||
result := path.DataString()
|
||||
if !strings.HasPrefix(result, "M30 50") {
|
||||
t.Errorf("Circle should start at correct position, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_ToSVG(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
renderer.SetBackground("#ffffff", 1.0)
|
||||
|
||||
renderer.BeginShape("#ff0000")
|
||||
points := []engine.Point{
|
||||
{X: 0, Y: 0},
|
||||
{X: 10, Y: 0},
|
||||
{X: 10, Y: 10},
|
||||
}
|
||||
renderer.AddPolygon(points)
|
||||
|
||||
svg := renderer.ToSVG()
|
||||
|
||||
// Check SVG structure
|
||||
if !strings.Contains(svg, `<svg xmlns="http://www.w3.org/2000/svg"`) {
|
||||
t.Error("SVG should contain proper xmlns")
|
||||
}
|
||||
if !strings.Contains(svg, `width="100" height="100"`) {
|
||||
t.Error("SVG should contain correct dimensions")
|
||||
}
|
||||
if !strings.Contains(svg, `viewBox="0 0 100 100"`) {
|
||||
t.Error("SVG should contain correct viewBox")
|
||||
}
|
||||
if !strings.Contains(svg, `<rect width="100%" height="100%" fill="#ffffff"/>`) {
|
||||
t.Error("SVG should contain background rect")
|
||||
}
|
||||
if !strings.Contains(svg, `<path fill="#ff0000" d="M0 0L10 0L10 10Z"/>`) {
|
||||
t.Error("SVG should contain path with correct data")
|
||||
}
|
||||
if !strings.HasSuffix(svg, "</svg>") {
|
||||
t.Error("SVG should end with closing tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_ToSVGWithoutBackground(t *testing.T) {
|
||||
renderer := NewSVGRenderer(50)
|
||||
|
||||
renderer.BeginShape("#0000ff")
|
||||
center := engine.Point{X: 25, Y: 25}
|
||||
renderer.AddCircle(center, 10, false)
|
||||
|
||||
svg := renderer.ToSVG()
|
||||
|
||||
// Should not contain background rect
|
||||
if strings.Contains(svg, "<rect") {
|
||||
t.Error("SVG without background should not contain rect")
|
||||
}
|
||||
// Should contain the circle path
|
||||
if !strings.Contains(svg, `fill="#0000ff"`) {
|
||||
t.Error("SVG should contain circle path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSVGRenderer_BackgroundWithOpacity(t *testing.T) {
|
||||
renderer := NewSVGRenderer(100)
|
||||
renderer.SetBackground("#cccccc", 0.5)
|
||||
|
||||
svg := renderer.ToSVG()
|
||||
|
||||
if !strings.Contains(svg, `opacity="0.50"`) {
|
||||
t.Error("SVG should contain opacity attribute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSvgValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1.0, "1"},
|
||||
{1.5, "1.5"},
|
||||
{1.23456, "1.2"},
|
||||
{1.26, "1.3"},
|
||||
{10.0, "10"},
|
||||
{10.1, "10.1"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if got := svgValue(test.input); got != test.expected {
|
||||
t.Errorf("svgValue(%v) = %v, want %v", test.input, got, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSvgValueRounding(t *testing.T) {
|
||||
// Test cases to verify "round half up" behavior matches JavaScript implementation
|
||||
testCases := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{12.0, "12"},
|
||||
{12.2, "12.2"},
|
||||
{12.25, "12.3"}, // Key case that fails with math.Round (would be "12.2")
|
||||
{12.35, "12.4"}, // Another case to verify consistent behavior
|
||||
{12.45, "12.5"}, // Another key case
|
||||
{12.75, "12.8"},
|
||||
{-12.25, "-12.2"}, // Test negative rounding
|
||||
{-12.35, "-12.3"},
|
||||
{50.45, "50.5"}, // Real-world case from avatar generation
|
||||
{50.55, "50.6"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("Input_%f", tc.input), func(t *testing.T) {
|
||||
got := svgValue(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("svgValue(%f) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
59
internal/util/hash.go
Normal file
59
internal/util/hash.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ParseHex parses a hexadecimal value from the hash string
|
||||
// This implementation is shared between engine and jdenticon packages for consistency
|
||||
func ParseHex(hash string, startPosition, octets int) (int, error) {
|
||||
// Handle negative indices (count from end like JavaScript)
|
||||
if startPosition < 0 {
|
||||
startPosition = len(hash) + startPosition
|
||||
}
|
||||
|
||||
// Ensure we don't go out of bounds
|
||||
if startPosition < 0 || startPosition >= len(hash) {
|
||||
return 0, fmt.Errorf("parseHex: position %d out of bounds for hash length %d", startPosition, len(hash))
|
||||
}
|
||||
|
||||
// If octets is 0 or negative, read from startPosition to end (like JavaScript default)
|
||||
end := len(hash)
|
||||
if octets > 0 {
|
||||
end = startPosition + octets
|
||||
if end > len(hash) {
|
||||
end = len(hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract substring and parse as hexadecimal
|
||||
substr := hash[startPosition:end]
|
||||
if len(substr) == 0 {
|
||||
return 0, fmt.Errorf("parseHex: empty substring at position %d", startPosition)
|
||||
}
|
||||
|
||||
result, err := strconv.ParseInt(substr, 16, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parseHex: failed to parse hex '%s' at position %d: %w", substr, startPosition, err)
|
||||
}
|
||||
|
||||
return int(result), nil
|
||||
}
|
||||
|
||||
// IsValidHash checks if a hash string is valid for jdenticon generation
|
||||
// This implementation is shared between engine and jdenticon packages for consistency
|
||||
func IsValidHash(hash string) bool {
|
||||
if len(hash) < 11 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all characters are valid hexadecimal
|
||||
for _, r := range hash {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user