1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-27 13:48:49 +08:00
termdash/draw/braille_circle.go
Jakub Sobon 50a734d77a
Ability to clear pixels.
- Bugfixes in the braille canvas.
- BrailleLine can clear pixels.
- BrailleCircle can clear pixels.
2019-01-19 01:59:46 -05:00

312 lines
9.0 KiB
Go

// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package draw
// braille_circle.go contains code that draws circles on a braille canvas.
import (
"fmt"
"image"
"github.com/mum4k/termdash/canvas/braille"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/numbers"
)
// BrailleCircleOption is used to provide options to BrailleCircle.
type BrailleCircleOption interface {
// set sets the provided option.
set(*brailleCircleOptions)
}
// brailleCircleOptions stores the provided options.
type brailleCircleOptions struct {
cellOpts []cell.Option
filled bool
pixelChange braillePixelChange
arcOnly bool
startDegree int
endDegree int
}
// newBrailleCircleOptions returns a new brailleCircleOptions instance.
func newBrailleCircleOptions() *brailleCircleOptions {
return &brailleCircleOptions{
pixelChange: braillePixelChangeSet,
}
}
// validate validates the provided options.
func (opts *brailleCircleOptions) validate() error {
if !opts.arcOnly {
return nil
}
const (
min = 0
max = 360
)
if got := opts.startDegree; got < min || got > max {
return fmt.Errorf("invalid starting degree for the arc %d, must be in range %d <= degree <= %d", got, min, max)
}
if got := opts.endDegree; got < min || got > max {
return fmt.Errorf("invalid ending degree for the arc %d, must be in range %d <= degree <= %d", got, min, max)
}
if opts.startDegree == opts.endDegree {
return fmt.Errorf("invalid degree range, start %d and end %d cannot be equal", opts.startDegree, opts.endDegree)
}
return nil
}
// brailleCircleOption implements BrailleCircleOption.
type brailleCircleOption func(*brailleCircleOptions)
// set implements BrailleCircleOption.set.
func (o brailleCircleOption) set(opts *brailleCircleOptions) {
o(opts)
}
// BrailleCircleCellOpts sets options on the cells that contain the line.
// Cell options on a braille canvas can only be set on the entire cell, not per
// pixel.
func BrailleCircleCellOpts(cOpts ...cell.Option) BrailleCircleOption {
return brailleCircleOption(func(opts *brailleCircleOptions) {
opts.cellOpts = cOpts
})
}
// BrailleCircleFilled indicates that the drawn circle should be filled.
func BrailleCircleFilled() BrailleCircleOption {
return brailleCircleOption(func(opts *brailleCircleOptions) {
opts.filled = true
})
}
// BrailleCircleArcOnly indicates that only a portion of the circle should be drawn.
// The arc will be between the two provided angles in degrees.
// Each angle must be in range 0 <= angle <= 360. Start and end must not be equal.
// The zero angle is on the X axis, angles grow counter-clockwise.
func BrailleCircleArcOnly(startDegree, endDegree int) BrailleCircleOption {
return brailleCircleOption(func(opts *brailleCircleOptions) {
opts.arcOnly = true
opts.startDegree = startDegree
opts.endDegree = endDegree
})
}
// BrailleCircleClearPixels changes the behavior of BrailleCircle, so that it
// clears the pixels belonging to the circle instead of setting them.
// Useful in order to "erase" a circle from the canvas as opposed to drawing one.
func BrailleCircleClearPixels() BrailleCircleOption {
return brailleCircleOption(func(opts *brailleCircleOptions) {
opts.pixelChange = braillePixelChangeClear
})
}
// BrailleCircle draws an approximated circle with the specified mid point and radius.
// The mid point must be a valid pixel within the canvas.
// All the points that form the circle must fit into the canvas.
// The smallest valid radius is one.
func BrailleCircle(bc *braille.Canvas, mid image.Point, radius int, opts ...BrailleCircleOption) error {
if ar := bc.Area(); !mid.In(ar) {
return fmt.Errorf("unable to draw circle with mid point %v which is outside of the braille canvas area %v", mid, ar)
}
if min := 1; radius < min {
return fmt.Errorf("unable to draw circle with radius %d, must be in range %d <= radius", radius, min)
}
opt := newBrailleCircleOptions()
for _, o := range opts {
o.set(opt)
}
if err := opt.validate(); err != nil {
return err
}
points := circlePoints(mid, radius)
if opt.arcOnly {
points = arcPoints(points, mid, radius, opt)
}
if opt.filled {
lineOpts := []BrailleLineOption{
BrailleLineCellOpts(opt.cellOpts...),
}
if opt.pixelChange == braillePixelChangeClear {
lineOpts = append(lineOpts, BrailleLineClearPixels())
}
for _, pts := range groupByY(points) {
if err := BrailleLine(bc, pts[0], pts[1], lineOpts...); err != nil {
return fmt.Errorf("failed to fill circle with mid:%v, start:%d degrees end:%d degrees, BrailleLine => %v", mid, opt.startDegree, opt.endDegree, err)
}
}
return nil
}
for _, p := range points {
switch opt.pixelChange {
case braillePixelChangeSet:
if err := bc.SetPixel(p, opt.cellOpts...); err != nil {
return fmt.Errorf("failed to draw circle with mid:%v, start:%d degrees end:%d degrees, SetPixel => %v", mid, opt.startDegree, opt.endDegree, err)
}
case braillePixelChangeClear:
if err := bc.ClearPixel(p, opt.cellOpts...); err != nil {
return fmt.Errorf("failed to erase circle with mid:%v, start:%d degrees end:%d degrees, ClearPixel => %v", mid, opt.startDegree, opt.endDegree, err)
}
}
}
return nil
}
// groupByY groups the points by their Y coordinate.
// Creates a map of Y coordinates to two points on that Y row.
// The points are the point with the smallest and the largest X coordinate.
// This is used to fill a circle or an arc - by drawing lines between these
// points.
func groupByY(points []image.Point) map[int][]image.Point {
groupped := map[int][]int{} // maps y -> x
for _, p := range points {
groupped[p.Y] = append(groupped[p.Y], p.X)
}
res := map[int][]image.Point{}
for y, pts := range groupped {
min, max := numbers.MinMaxInts(pts)
res[y] = []image.Point{
{min, y},
{max, y},
}
}
return res
}
// filterByAngle filters the provided points, returning only those that fall
// within the starting and the ending angle.
func filterByAngle(points []image.Point, mid image.Point, start, end int) []image.Point {
var res []image.Point
for _, p := range points {
angle := numbers.CircleAngleAtPoint(p, mid)
// Edge case, this might mean 0 or 360.
// Decide based on where we are starting.
if angle == 0 && start > 0 {
angle = 360
}
ranges := toDegreeRanges(start, end)
for _, r := range ranges {
if r.in(angle) {
res = append(res, p)
break
}
}
}
return res
}
// intRange represents a range of integers.
type intRange struct {
start int
end int
}
// in asserts whether the integer is in the range.
func (ir *intRange) in(i int) bool {
return i >= ir.start && i <= ir.end
}
// toDegreeRanges converts the start and end angles in degrees into ranges of
// angles. Solves cases where the 0/360 point falls within the range.
func toDegreeRanges(start, end int) []*intRange {
if start == 360 && end == 0 {
start, end = end, start
}
if start < end {
return []*intRange{
{start, end},
}
}
// The range is crossing the 0/360 degree point.
// Break it into multiple ranges.
return []*intRange{
{start, 360},
{0, end},
}
}
// arcPoints returns only those points that belong to an incomplete circle.
func arcPoints(points []image.Point, mid image.Point, radius int, opt *brailleCircleOptions) []image.Point {
points = filterByAngle(points, mid, opt.startDegree, opt.endDegree)
if opt.filled {
// If we are filling the angle - add points representing the lines from
// the mid point to the start and end point of the arc.
startP := numbers.CirclePointAtAngle(opt.startDegree, mid, radius)
endP := numbers.CirclePointAtAngle(opt.endDegree, mid, radius)
points = append(points, brailleLinePoints(mid, startP)...)
points = append(points, brailleLinePoints(mid, endP)...)
}
return points
}
// circlePoints returns a list of points that represent a circle with
// the specified mid point and radius.
func circlePoints(mid image.Point, radius int) []image.Point {
var points []image.Point
// Bresenham algorithm.
// https://en.wikipedia.org/wiki/Midpoint_circle_algorithm
x := radius
y := 0
dx := 1
dy := 1
diff := dx - (radius << 1) // Cheap multiplication by two.
for x >= y {
points = append(
points,
image.Point{mid.X + x, mid.Y + y},
image.Point{mid.X + y, mid.Y + x},
image.Point{mid.X - y, mid.Y + x},
image.Point{mid.X - x, mid.Y + y},
image.Point{mid.X - x, mid.Y - y},
image.Point{mid.X - y, mid.Y - x},
image.Point{mid.X + y, mid.Y - x},
image.Point{mid.X + x, mid.Y - y},
)
if diff <= 0 {
y++
diff += dy
dy += 2
}
if diff > 0 {
x--
dx += 2
diff += dx - (radius << 1)
}
}
return points
}