1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Adding functions that calculate angles.

This commit is contained in:
Jakub Sobon 2019-01-21 00:51:05 -05:00
parent 20c31cb800
commit 6276788be2
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
2 changed files with 695 additions and 0 deletions

224
trig/trig.go Normal file
View File

@ -0,0 +1,224 @@
// 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
}

471
trig/trig_test.go Normal file
View File

@ -0,0 +1,471 @@
// 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
import (
"fmt"
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
)
func TestCirclePointAtAngleAndAngle(t *testing.T) {
tests := []struct {
degrees int
mid image.Point
radius int
want image.Point
}{
{0, image.Point{0, 0}, 1, image.Point{1, 0}},
{90, image.Point{0, 0}, 1, image.Point{0, -1}},
{180, image.Point{0, 0}, 1, image.Point{-1, 0}},
{270, image.Point{0, 0}, 1, image.Point{0, 1}},
// Non-zero mid point.
{0, image.Point{5, 5}, 1, image.Point{6, 5}},
{90, image.Point{5, 5}, 1, image.Point{5, 4}},
{180, image.Point{5, 5}, 1, image.Point{4, 5}},
{270, image.Point{5, 5}, 1, image.Point{5, 6}},
{0, image.Point{1, 1}, 1, image.Point{2, 1}},
{90, image.Point{1, 1}, 1, image.Point{1, 0}},
{180, image.Point{1, 1}, 1, image.Point{0, 1}},
{270, image.Point{1, 1}, 1, image.Point{1, 2}},
// Larger radius.
{0, image.Point{0, 0}, 11, image.Point{11, 0}},
{90, image.Point{0, 0}, 11, image.Point{0, -11}},
{180, image.Point{0, 0}, 11, image.Point{-11, 0}},
{270, image.Point{0, 0}, 11, image.Point{0, 11}},
// Other angles.
{27, image.Point{0, 0}, 11, image.Point{10, -5}},
{68, image.Point{0, 0}, 11, image.Point{4, -10}},
{333, image.Point{2, 2}, 2, image.Point{4, 3}},
{153, image.Point{2, 2}, 2, image.Point{0, 1}},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("CirclePointAtAngle %v %v %v", tc.degrees, tc.mid, tc.radius), func(t *testing.T) {
got := CirclePointAtAngle(tc.degrees, tc.mid, tc.radius)
if got != tc.want {
t.Errorf("CirclePointAtAngle(%v, %v, %v) => %v, want %v", tc.degrees, tc.mid, tc.radius, got, tc.want)
}
})
t.Run(fmt.Sprintf("CircleAngleAtPoint %v %v", tc.want, tc.mid), func(t *testing.T) {
got := CircleAngleAtPoint(tc.want, tc.mid)
want := tc.degrees
if got != want {
t.Errorf("CircleAngleAtPoint(%v, %v) => %v, want %v", tc.want, tc.mid, got, want)
}
})
}
}
func TestPointIsIn(t *testing.T) {
tests := []struct {
desc string
point image.Point
shape []image.Point
want bool
}{
{
desc: "no points provided",
point: image.Point{0, 0},
shape: nil,
want: false,
},
{
desc: "point is on the shape",
point: image.Point{0, 0},
shape: []image.Point{
{0, 0},
},
want: false,
},
{
desc: "point is left of the shape",
point: image.Point{0, 1},
shape: []image.Point{
{1, 0}, {2, 0}, {3, 0},
{1, 1}, {3, 1},
{1, 2}, {2, 2}, {3, 2},
},
want: false,
},
{
desc: "point is in a shape whose border gets crossed once",
point: image.Point{2, 1},
shape: []image.Point{
{1, 0}, {2, 0}, {3, 0},
{1, 1}, {3, 1},
{1, 2}, {2, 2}, {3, 2},
},
want: true,
},
{
desc: "point is in an U shape whose border gets crossed multiple times",
point: image.Point{1, 1},
shape: []image.Point{
{0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0}, {5, 0}, {6, 0},
{0, 1}, {2, 1}, {4, 1}, {6, 1},
{0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2}, {5, 2},
},
want: true,
},
{
desc: "point is in a triangle",
point: image.Point{3, 1},
shape: []image.Point{
{3, 0},
{2, 1}, {4, 1},
{1, 2}, {2, 2}, {3, 2}, {4, 2}, {5, 2},
},
want: true,
},
{
desc: "ignores multiple shape points on the same row",
point: image.Point{2, 1},
shape: []image.Point{
{1, 0}, {2, 0}, {3, 0},
{1, 1}, {3, 1}, {4, 1}, {5, 1},
{1, 2}, {2, 2}, {3, 2},
},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := PointIsIn(tc.point, tc.shape)
if got != tc.want {
t.Errorf("PointIsIn => %v, want %v", got, tc.want)
}
})
}
}
func TestRangeSize(t *testing.T) {
tests := []struct {
desc string
start int
end int
want int
wantErr bool
}{
{
desc: "invalid start, too small",
start: MinAngle - 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid start, too large",
start: MaxAngle + 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid end, too small",
start: MinAngle,
end: MinAngle - 1,
wantErr: true,
},
{
desc: "invalid end, too large",
start: MinAngle,
end: MaxAngle + 1,
wantErr: true,
},
{
desc: "zero range starting at zero",
start: 0,
end: 0,
want: 0,
},
{
desc: "zero range starting at max angle",
start: 360,
end: 360,
want: 0,
},
{
desc: "range with size of one",
start: 1,
end: 2,
want: 1,
},
{
desc: "reverse range with size of 359",
start: 2,
end: 1,
want: 359,
},
{
desc: "range that crosses 360",
start: 350,
end: 10,
want: 20,
},
{
desc: "reverse range that doesn't cross 360",
start: 10,
end: 350,
want: 340,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := RangeSize(tc.start, tc.end)
if (err != nil) != tc.wantErr {
t.Errorf("RangeSize => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if got != tc.want {
t.Errorf("RangeSize => %v, want %v", got, tc.want)
}
})
}
}
func TestRangeMid(t *testing.T) {
tests := []struct {
desc string
start int
end int
want int
wantErr bool
}{
{
desc: "invalid start, too small",
start: MinAngle - 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid start, too large",
start: MaxAngle + 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid end, too small",
start: MinAngle,
end: MinAngle - 1,
wantErr: true,
},
{
desc: "invalid end, too large",
start: MinAngle,
end: MaxAngle + 1,
wantErr: true,
},
{
desc: "zero range",
start: 0,
end: 0,
want: 0,
},
{
desc: "one degree range",
start: 0,
end: 1,
want: 0,
},
{
desc: "three degree range",
start: 0,
end: 3,
want: 1,
},
{
desc: "range that crosses 360, mid isn't 360",
start: 351,
end: 11,
want: 1,
},
{
desc: "range that crosses 360, mid is 360",
start: 350,
end: 10,
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := RangeMid(tc.start, tc.end)
if (err != nil) != tc.wantErr {
t.Errorf("RangeMid => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if got != tc.want {
t.Errorf("RangeMid => %v, want %v", got, tc.want)
}
})
}
}
func TestFilterByAngle(t *testing.T) {
tests := []struct {
desc string
points []image.Point
mid image.Point
start int
end int
want []image.Point
wantErr bool
}{
{
desc: "invalid mid, negative X coordinate",
mid: image.Point{-1, 0},
start: MinAngle,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid mid, negative Y coordinate",
mid: image.Point{0, -1},
start: MinAngle,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid start, too small",
start: MinAngle - 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid start, too large",
start: MaxAngle + 1,
end: MaxAngle,
wantErr: true,
},
{
desc: "invalid end, too small",
start: MinAngle,
end: MinAngle - 1,
wantErr: true,
},
{
desc: "invalid end, too large",
start: MinAngle,
end: MaxAngle + 1,
wantErr: true,
},
{
desc: "full first quadrant",
points: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
mid: image.Point{1, 1},
start: 0,
end: 90,
want: []image.Point{
{1, 0}, {2, 0},
{1, 1}, {2, 1},
},
},
{
desc: "partial second quadrant",
points: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
mid: image.Point{1, 1},
start: 130,
end: 140,
want: []image.Point{
{0, 0},
},
},
{
desc: "range crosses 360",
points: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
mid: image.Point{1, 1},
start: 310,
end: 50,
want: []image.Point{
{2, 0},
{1, 1}, {2, 1},
{2, 2},
},
},
{
desc: "full circle",
points: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
mid: image.Point{1, 1},
start: 0,
end: 360,
want: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
},
{
desc: "full circle in reverse",
points: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
mid: image.Point{1, 1},
start: 360,
end: 0,
want: []image.Point{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2},
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := FilterByAngle(tc.points, tc.mid, tc.start, tc.end)
if (err != nil) != tc.wantErr {
t.Errorf("FilterByAngle => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("FilterByAngle => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}