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
|
||||
}
|
||||
Reference in New Issue
Block a user