353 lines
9.7 KiB
Go
353 lines
9.7 KiB
Go
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
|
|
} |