termdash/trig/trig.go

225 lines
5.7 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 trig implements various trigonometrical calculations.
package trig
import (
"fmt"
"image"
"math"
"sort"
"github.com/mum4k/termdash/numbers"
)
// CirclePointAtAngle given an angle in degrees and a circle midpoint and
// radius, calculates coordinates of a point on the circle at that angle.
// Angles are zero at the X axis and grow counter-clockwise.
func CirclePointAtAngle(degrees int, mid image.Point, radius int) image.Point {
angle := numbers.DegreesToRadians(degrees)
r := float64(radius)
x := mid.X + int(numbers.Round(r*math.Cos(angle)))
// Y coordinates grow down on the canvas.
y := mid.Y - int(numbers.Round(r*math.Sin(angle)))
return image.Point{x, y}
}
// CircleAngleAtPoint given a point on a circle and its midpoint,
// calculates the angle in degrees.
// Angles are zero at the X axis and grow counter-clockwise.
func CircleAngleAtPoint(point, mid image.Point) int {
adj := float64(point.X - mid.X)
opp := float64(mid.Y - point.Y)
if opp != 0 {
angle := math.Atan2(opp, adj)
return numbers.RadiansToDegrees(angle)
} else if adj >= 0 {
return 0
} else {
return 180
}
}
// PointIsIn asserts whether the provided point is inside of a shape outlined
// with the provided points.
// Does not verify that the shape is closed or complete, it merely counts the
// number of intersections with the shape on one row.
func PointIsIn(p image.Point, points []image.Point) bool {
maxX := p.X
set := map[image.Point]struct{}{}
for _, sp := range points {
set[sp] = struct{}{}
if sp.X > maxX {
maxX = sp.X
}
}
if _, ok := set[p]; ok {
// Not inside if it is on the shape.
return false
}
byY := map[int][]int{} // maps y->x
for p := range set {
byY[p.Y] = append(byY[p.Y], p.X)
}
for y := range byY {
sort.Ints(byY[y])
}
set = map[image.Point]struct{}{}
for y, xses := range byY {
set[image.Point{xses[0], y}] = struct{}{}
if len(xses) == 1 {
continue
}
for i := 1; i < len(xses); i++ {
if xses[i] != xses[i-1]+1 {
set[image.Point{xses[i], y}] = struct{}{}
}
}
}
crosses := 0
for x := p.X; x <= maxX; x++ {
if _, ok := set[image.Point{x, p.Y}]; ok {
crosses++
}
}
return crosses%2 != 0
}
const (
// MinAngle is the smallest valid angle in degrees.
MinAngle = 0
// MaxAngle is the largest valid angle in degrees.
MaxAngle = 360
)
// angleRange represents a range of angles in degrees.
// The range includes all angles such that start <= angle <= end.
type angleRange struct {
// start is the start if the range.
// This is always less or equal to the end.
start int
// end is the end of the range.
end int
}
// contains asserts whether the specified angle is in the range.
func (ar *angleRange) contains(angle int) bool {
return angle >= ar.start && angle <= ar.end
}
// normalizeRange normalizes the start and end angles in degrees into ranges of
// angles. Useful for cases where the 0/360 point falls within the range.
// E.g:
// 0,25 => angleRange{0, 26}
// 0,360 => angleRange{0, 361}
// 359,20 => angleRange{359, 361}, angleRange{0, 21}
func normalizeRange(start, end int) ([]*angleRange, error) {
if start < MinAngle || start > MaxAngle {
return nil, fmt.Errorf("invalid start angle:%d, must be in range %d <= start <= %d", start, MinAngle, MaxAngle)
}
if end < MinAngle || end > MaxAngle {
return nil, fmt.Errorf("invalid end angle:%d, must be in range %d <= end <= %d", end, MinAngle, MaxAngle)
}
if start == MaxAngle && end == 0 {
start, end = end, start
}
if start <= end {
return []*angleRange{
{start, end},
}, nil
}
// The range is crossing the 0/360 degree point.
// Break it into multiple ranges.
return []*angleRange{
{start, MaxAngle},
{0, end},
}, nil
}
// RangeSize returns the size of the degree range.
// E.g:
// 0,25 => 25
// 359,1 => 2
func RangeSize(start, end int) (int, error) {
ranges, err := normalizeRange(start, end)
if err != nil {
return 0, err
}
if len(ranges) == 1 {
return end - start, nil
}
return MaxAngle - start + end, nil
}
// RangeMid returns an angle that lies in the middle between start and end.
// E.g:
// 0,10 => 5
// 350,10 => 0
func RangeMid(start, end int) (int, error) {
ranges, err := normalizeRange(start, end)
if err != nil {
return 0, err
}
if len(ranges) == 1 {
return start + ((end - start) / 2), nil
}
length := MaxAngle - start + end
want := length / 2
res := start + want
return res % MaxAngle, nil
}
// FilterByAngle filters the provided points, returning only those that fall
// within the starting and the ending angle on a circle with the provided mid
// point.
func FilterByAngle(points []image.Point, mid image.Point, start, end int) ([]image.Point, error) {
var res []image.Point
ranges, err := normalizeRange(start, end)
if err != nil {
return nil, err
}
if mid.X < 0 || mid.Y < 0 {
return nil, fmt.Errorf("the mid point %v cannot have negative coordinates", mid)
}
for _, p := range points {
angle := CircleAngleAtPoint(p, mid)
// Edge case, this might mean 0 or 360.
// Decide based on where we are starting.
if angle == 0 && start > 0 {
angle = MaxAngle
}
for _, r := range ranges {
if r.contains(angle) {
res = append(res, p)
break
}
}
}
return res, nil
}