For a personal project that I've recently started I have to analyze the rotation of two shapes with sets of spokes, where the spokes are evenly-spaced, and split into a given number.
That is, if I have a shape with x
evenly-spaced spokes, and a shape with y
evenly-spaced spokes, I need to analyze two major properties:
What is the "Phase Difference"? That is, if one shape were rotating, how many degrees would it need to rotate before any spoke overlaps the other shape?
What is the number of overlaps in a single 360° rotation? That is, if one shape were rotating, how many times would any spoke overlap the other shape?
To start with, I'll provide the type of output this program produces. This is an animation of how one shape with 8 spokes and one shape with 9 spokes would overlap.
Each frame in the GIF is a single overlap. For this one, there are 72 such overlaps.
Next, we look at the console output (for this exact set):
Shape 1 Angle (8 spokes): 45.000000° / 0.785398 rads Shape 2 Angle (9 spokes): 40.000000° / 0.698132 rads Phase Difference: 5.000000° / 0.087266 rads (0.013889 rotations) Overlaps per Rotation: 72.000000
We note that the console tells us the difference in "Phase" (that is: how far must it rotate to overlap?) is 5°, and the number of overlaps is 72. This means the shapes would overlap 72 times in a given rotation, were either of them spinning.
All this stuff happens in three locations:
Graphics.fs
which just helps us draw points and lines from an "origin" properly:
module Spoke_Analyzer.Graphics
open System.Drawing
let drawLine (origin : Point) (g : Graphics) (pen : Pen) (start : Point, stop : Point) =
g.DrawLine(pen, Point(start.X + origin.X, start.Y + origin.Y), Point(stop.X + origin.X, stop.Y + origin.Y))
let drawPoint (origin : Point) (g : Graphics) (brush : Brush) (start : Point) width =
g.FillEllipse(brush, Rectangle(start.X + origin.X - (width / 2), start.Y + origin.Y - (width / 2), width, width))
Input.fs
which helps us consume CLI input:
module Spoke_Analyzer.Input
open System
let rec getInput convert validate prompt =
printf "%s" prompt
let input = () |> Console.ReadLine
if input |> validate then
input |> convert
else
printfn "Invalid, please try again."
getInput convert validate prompt
let getInputInt = getInput Int32.Parse (Int32.TryParse >> function | true, f when f > 0 -> true | _ -> false)
let getInputIntOption =
getInput
(function | "" -> None | s -> s |> Int32.Parse |> Some)
(function | "" -> true | s -> s |> Int32.TryParse |> function | true, f when f > 0 -> true | _ -> false)
let getInputDoubleOption =
getInput
(function | "" -> None | s -> s |> Double.Parse |> Some)
(function | "" -> true | s -> s |> Double.TryParse |> function | true, f when f >= 0. && f <= 1. -> true | _ -> false)
let getInputDouble = getInput Double.Parse (Double.TryParse >> function | true, f when f >= 0. && f <= 1. -> true | _ -> false)
let getInputFileOption (file : string) =
getInput
(function | "" -> None | s -> Some s)
(function
| "" -> true
| s ->
if Uri.IsWellFormedUriString(sprintf "file:///%s" (s.Replace('\\', '/')), UriKind.RelativeOrAbsolute) then
let test =
if s.EndsWith(file) = false && s.EndsWith(sprintf "%s.exe" file) = false then
let t = System.IO.Path.Combine(s, file)
if System.IO.File.Exists(t) then t
else System.IO.Path.Combine(s, sprintf "%s.exe" file)
else s
if System.IO.File.Exists(test) then
true
else false
else false)
And finally, the raw Program.fs
:
open System
open System.Drawing
open Spoke_Analyzer
let inline degToRad deg = deg * Math.PI / 180.
let inline radToDeg rad = rad * 180. / Math.PI
[<Literal>]
let FULL_CIRCLE = 360.
[<Literal>]
let ROTATION_OFFSET = -90. // -FULL_CIRCLE / 4.
[<Literal>]
let IMAGE_DIR = "temp/images"
[<EntryPoint>]
let main argv =
let imageWidth = Input.getInputIntOption "Enter the image dimension (whole number > 0) [160]: " |> Option.defaultValue 160
let spoke1 = Input.getInputInt "Enter the number of spokes for shape 1 (whole number > 0): "
let spoke2 = Input.getInputInt "Enter the number of spokes for shape 2 (whole number > 0): "
let offset = Input.getInputDoubleOption "Enter the radial offset in percentage (0.0 - 1.0) [0]: " |> Option.defaultValue 0.
let ffmpeg = Input.getInputFileOption "ffmpeg" "Enter the location of ffmpeg (if available) []: "
let fps = ffmpeg |> Option.bind (fun s -> Input.getInputIntOption "Enter the fps of the output (whole number > 0) [24]: ") |> Option.defaultValue 24
let angleDegrees1 = FULL_CIRCLE / (spoke1 |> float)
let angleDegrees2 = FULL_CIRCLE / (spoke2 |> float)
printfn ""
let rec getRotation small large =
if small > large then
getRotation large small
else
if small = large then small
elif large = FULL_CIRCLE then small
else
let v1 = large / small
let divisions = v1 |> int |> float
let v2 = min (large - small) (large - small * divisions)
let v1 = FULL_CIRCLE / v1
min v1 v2
let rotation = getRotation angleDegrees1 angleDegrees2
let rotations = FULL_CIRCLE / rotation
let rec drawSaveImage i =
use bmp = new Bitmap(imageWidth, imageWidth)
do
use g = bmp |> Graphics.FromImage
g.SmoothingMode <- Drawing2D.SmoothingMode.AntiAlias
use fillBrush = new SolidBrush(Color.FromArgb(255, 32, 32, 32))
g.FillRectangle(fillBrush, Rectangle(0, 0, bmp.Width, bmp.Height))
let origin = Point(bmp.Width / 2, bmp.Height / 2)
use pen1 = new Pen(Color.FromArgb(255, 224, 32, 32), 2.5f)
use brush1 = new SolidBrush(Color.FromArgb(255, 224, 32, 32))
let drawLine1 = Spoke_Analyzer.Graphics.drawLine origin g pen1
use pen2 = new Pen(Color.FromArgb(255, 32, 224, 32), 1.5f)
use brush2 = new SolidBrush(Color.FromArgb(255, 32, 224, 32))
let drawLine2 = Spoke_Analyzer.Graphics.drawLine origin g pen2
let drawPoint = Spoke_Analyzer.Graphics.drawPoint origin g
let distance = (imageWidth / 2) |> float
let rec drawSpoke drawLine distance offset angle max num =
if num = max then ()
else
drawLine
(Point(0, 0),
Point((distance * ((angle * (num |> float) + offset) |> degToRad |> cos)) |> int,
(distance * ((angle * (num |> float) + offset) |> degToRad |> sin)) |> int))
drawSpoke drawLine distance offset angle max (num + 1)
drawSpoke drawLine1 distance ROTATION_OFFSET angleDegrees1 spoke1 0
drawSpoke drawLine2 distance (angleDegrees2 * offset + ROTATION_OFFSET + rotation * (i |> float)) angleDegrees2 spoke2 0
drawPoint brush1 (Point(0, -distance |> int)) 8
drawPoint brush2 (Point((distance * ((rotation * (i |> float) + ROTATION_OFFSET) |> degToRad |> cos)) |> int,
(distance * ((rotation * (i |> float) + ROTATION_OFFSET) |> degToRad |> sin)) |> int)) 6
()
bmp.Save(sprintf "%s/rot_%i.png" IMAGE_DIR i, Imaging.ImageFormat.Png)
if Double.IsInfinity(rotations) |> not && (i |> float) + 1. < rotations then drawSaveImage (i + 1)
()
if IMAGE_DIR |> System.IO.Directory.Exists |> not then IMAGE_DIR|> System.IO.Directory.CreateDirectory |> ignore
IMAGE_DIR |> System.IO.Directory.GetFiles |> Array.iter System.IO.File.Delete
drawSaveImage 0
printfn "Images saved."
match ffmpeg with
| Some ffmpeg ->
let ffmpeg =
if ffmpeg.EndsWith("ffmpeg") = false && ffmpeg.EndsWith("ffmpeg.exe") = false then
System.IO.Path.Combine(ffmpeg, "ffmpeg")
else ffmpeg
printfn "Running ffmpeg..."
System
.Diagnostics
.Process
.Start(ffmpeg, sprintf "-framerate %i -f image2 -i %s/rot_%%d.png -c:v libx264 -crf 0 -r %i -preset ultrafast -tune stillimage %s/temp.avi" fps IMAGE_DIR fps IMAGE_DIR)
.WaitForExit()
System
.Diagnostics
.Process
.Start(ffmpeg, sprintf "-i %s/temp.avi -pix_fmt rgb24 %s/_final.gif" IMAGE_DIR IMAGE_DIR)
.WaitForExit()
printfn "Images converted to gif."
printfn ""
| _ -> ()
printfn "Shape 1 Angle (%i spokes): %f° / %f rads" spoke1 angleDegrees1 (angleDegrees1 |> degToRad)
printfn "Shape 2 Angle (%i spokes): %f° / %f rads" spoke2 angleDegrees2 (angleDegrees2 |> degToRad)
printfn "Phase Difference: %f° / %f rads (%f rotations)" rotation (rotation |> degToRad) (rotation / FULL_CIRCLE)
printfn "Overlaps per Rotation: %f" (rotations)
0
Overall, nothing overly-complex here.
Appreciate any/all advice. Source code is on GitHub: