6
\$\begingroup\$

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:

  1. 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?

  2. 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.

A rotational diagram

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:

https://github.com/EBrown8534/Spoke_Analyzer

\$\endgroup\$
4
\$\begingroup\$

The two things that currently bother me most about this code are Naming and Abstraction.

Don't get me wrong, this is something pretty cool and it's really well-crafted for the most part. But I'm still waiting for this to go the last mile:

There is 12 local variables or functions in your main. I reckon you can easily reduce that to half as many by adding some more abstraction.
While we're working on that, we should also look at the naming of these things.

Rough steps to make this much easier to grasp:

  1. Extract getRotation into a separate module and rename it to computePhaseDelta or something like that. Note that neither "Rotation" nor "Phase Difference" are accurate descriptions of what you try to describe.
    A phase difference is really only meaningful when comparing waveforms with the same frequency. For all other waveforms the phase difference is a function of time. I do prefer "phase difference" over "rotation", though.

  2. Extract input-prompting into it's own function and encapsulate all information relating to the problem into a single type. This allows you to utterly abstract all the "low-level" prompting and handling away another step.

  3. Move the image-drawing local function out of main. It's an infodump that's interrupting the control flow. It also adds another bunch of local variables.

  4. Extract the gif conversion into it's own function. It's comparatively long as well.

Overall main is intermingling a lot of abstaction levels. To avoid that you'll need to extract more functions.


Now that I've bashed around there, I should mention that I found the Input module somewhat annoying to follow, mostly because of the line length and the the way functions are chained. Then again writing nice and abstract input functions in a functional language often is somewhat ugly ;)

I like very much the way you've set up drawLine and drawPoint. It's a bit hard to grasp on first reading, but you've made them "coordinate-system aware". They are also trivial to partially apply, which lends itself really well to functional programming.

You could've used extended partial application to reduce the complexity inside drawSaveImage after encapsulating it into a separate module (or attaching it to Graphics.fs). While you used it to construct drawLine1, drawLine2 and drawPoint, you have not gained that much because all of the setup for colors and pens is still in drawSaveImage

On that note: you're recursing inside drawSaveImage. I'd have preferred the function to only draw and save a single image given it's name. That might have also allowed you to simplify the pen and draw* setup across all invocations of the function by passing these as parameters.


Overall I very much like this code, but I want to see the abstraction it performs taken to it's logical conclusion.

\$\endgroup\$
  • \$\begingroup\$ With regard to "Phase Difference": you wouldn't believe how long we spent in the F# Slack trying to come up with a name for that...my original was worse, still trying to find a good one. (Maybe "Angle Offset"?) \$\endgroup\$ – Der Kommissar Feb 11 at 19:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.