diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 9f0657f..fb7d088 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -386,13 +386,32 @@ func TestWheelOfNamesBeta_DrawsOptionLabelsInsideSlices(t *testing.T) { segment := 2 * math.Pi / float64(len(options)) angle := rotation + segment/2 centerX := wheelBetaSize/2 + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius))) - baselineY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + wheelBetaSliceLabelBaselineOffset - bounds := image.Rect(centerX-28, baselineY-14, centerX+28, baselineY+2) + centerY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + bounds := image.Rect(centerX-28, centerY-28, centerX+28, centerY+28) if got := countColorIndex(img, bounds, 1); got == 0 { t.Fatalf("slice label dark pixels = %d, want > 0", got) } } +func TestWheelOfNamesBeta_RotatesOptionLabelsWithSlices(t *testing.T) { + options := []string{"Rotate", "Teacher", "Parent", "Staff"} + rotation := -3 * math.Pi / 4 + img := renderWheelBetaFrame(options, 0, rotation, false) + + segment := 2 * math.Pi / float64(len(options)) + angle := rotation + segment/2 + centerX := wheelBetaSize/2 + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius))) + centerY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + searchBounds := image.Rect(centerX-28, centerY-32, centerX+28, centerY+32) + labelBounds, ok := colorIndexBounds(img, searchBounds, wheelBetaInkColorIndex) + if !ok { + t.Fatalf("slice label dark pixels missing in %v", searchBounds) + } + if labelBounds.Dy() <= labelBounds.Dx() { + t.Fatalf("slice label bounds = %v, want taller than wide for rotated label", labelBounds) + } +} + func countColorIndex(img *image.Paletted, bounds image.Rectangle, colorIndex byte) int { bounds = bounds.Intersect(img.Bounds()) count := 0 @@ -406,6 +425,37 @@ func countColorIndex(img *image.Paletted, bounds image.Rectangle, colorIndex byt return count } +func colorIndexBounds(img *image.Paletted, bounds image.Rectangle, colorIndex byte) (image.Rectangle, bool) { + bounds = bounds.Intersect(img.Bounds()) + minX, minY := bounds.Max.X, bounds.Max.Y + maxX, maxY := bounds.Min.X, bounds.Min.Y + found := false + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if img.ColorIndexAt(x, y) != colorIndex { + continue + } + found = true + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x+1 > maxX { + maxX = x + 1 + } + if y+1 > maxY { + maxY = y + 1 + } + } + } + if !found { + return image.Rectangle{}, false + } + return image.Rect(minX, minY, maxX, maxY), true +} + func TestWheelOfNamesBeta_SendsAnimationWithoutSpoilingCaption(t *testing.T) { rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice")) diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_beta_drawing.go index 7ed7092..fbd64c3 100644 --- a/internal/modules/misc/wheelofnames_beta_drawing.go +++ b/internal/modules/misc/wheelofnames_beta_drawing.go @@ -2,6 +2,7 @@ package misc import ( "image" + "image/color" "math" "strings" @@ -11,8 +12,7 @@ import ( ) const ( - wheelBetaSliceLabelRadius = 64 - wheelBetaSliceLabelBaselineOffset = 5 + wheelBetaSliceLabelRadius = 64 ) func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) { @@ -25,10 +25,10 @@ func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float6 for idx, option := range options { angle := rotation + (float64(idx)+0.5)*segment centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius))) - baselineY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + wheelBetaSliceLabelBaselineOffset + centerY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) text := asciiWheelText(option, limit) - drawCenteredTextAt(img, text, centerX+1, baselineY+1, 2) - drawCenteredTextAt(img, text, centerX, baselineY, 1) + drawRotatedCenteredTextAt(img, text, centerX+1, centerY+1, angle, wheelBetaPaperColorIndex) + drawRotatedCenteredTextAt(img, text, centerX, centerY, angle, wheelBetaInkColorIndex) } } @@ -215,6 +215,38 @@ func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int drawer.DrawString(text) } +func drawRotatedCenteredTextAt(img *image.Paletted, text string, centerX, centerY int, angle float64, colorIndex byte) { + face := basicfont.Face7x13 + padding := 2 + metrics := face.Metrics() + textWidth := font.MeasureString(face, text).Ceil() + textHeight := metrics.Height.Ceil() + mask := image.NewAlpha(image.Rect(0, 0, textWidth+padding*2, textHeight+padding*2)) + drawer := font.Drawer{ + Dst: mask, + Src: image.NewUniform(color.Alpha{A: 255}), + Face: face, + Dot: fixed.P(padding, padding+metrics.Ascent.Ceil()), + } + drawer.DrawString(text) + + sourceCenterX := float64(mask.Bounds().Dx()-1) / 2 + sourceCenterY := float64(mask.Bounds().Dy()-1) / 2 + sin, cos := math.Sin(angle), math.Cos(angle) + for y := mask.Bounds().Min.Y; y < mask.Bounds().Max.Y; y++ { + for x := mask.Bounds().Min.X; x < mask.Bounds().Max.X; x++ { + if mask.AlphaAt(x, y).A == 0 { + continue + } + localX := float64(x) - sourceCenterX + localY := float64(y) - sourceCenterY + targetX := centerX + int(math.Round(localX*cos-localY*sin)) + targetY := centerY + int(math.Round(localX*sin+localY*cos)) + setWheelBetaPixel(img, targetX, targetY, colorIndex) + } + } +} + func asciiWheelText(s string, limit int) string { var b strings.Builder for _, r := range s {