diff --git a/trig/trig.go b/trig/trig.go new file mode 100644 index 0000000..3b832bf --- /dev/null +++ b/trig/trig.go @@ -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 +} diff --git a/trig/trig_test.go b/trig/trig_test.go new file mode 100644 index 0000000..a3ab27d --- /dev/null +++ b/trig/trig_test.go @@ -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) + } + + }) + } +}