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 }