Image
ScriptName = 'WP_platterControl'

-- **************************************************
-- Control a platter with speed scaling
-- Created by Maarten de Haas, Wigglepixel
-- **************************************************

--[[ ***** Licence & Warranty *****

    Copyright 2023/2024 - Maarten de Haas / Wigglepixel

    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

    Conditions require preservation of copyright and license notices.

    You must retain, in the Source form of any Derivative Works that
    You distribute, all copyright, patent, trademark, and attribution
    notices from the Source form of the Work, excluding those notices
    that do not pertain to any part of the Derivative Works.

    You can:
        Use   - use/reuse freely, even commercially
        Adapt - remix, transform, and build upon for any purpose
        Share - redistribute the material in any medium or format

    Adapt / Share under the following terms:
        Attribution - You must give appropriate credit, provide a link to
        the Apache 2.0 license, and indicate if changes were made. You may
        do so in any reasonable manner, but not in any way that suggests
        the licensor endorses you or your use.

    Licensed works, modifications and larger works may be distributed
    under different License terms and without source code.

    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.

    The Developer Maarten de Haas, Wigglepixel will not be liable for any direct,
    indirect or consequential loss of actual or anticipated - data, revenue,
    profits, business, trade or goodwill that is suffered as a result of the
    use of the software provided.

]]

-- **************************************************
-- General information about this script
-- **************************************************

WP_platterControl = {}

WP_platterControl.fps = nil

WP_platterControl.WP_UTILS_REQUIRED_VERSION = { 1, 3, 0 }
WP_platterControl.HIDDEN_BONE_NAME = '---                   '
WP_platterControl.STR_ARR_TO_CONCAT_STRING_DIVIDER = '%%WPDIV%%' -- FOR JOINING ARRAY STRINGS INTO A SINGLE STRING FOR PREFS STORAGE

-- SPEED DIAL DIRECTION MODES
WP_platterControl.SPEED_DIAL_MODE_PRESET_LEFT = 0
WP_platterControl.SPEED_DIAL_MODE_PRESET_UP = 1
WP_platterControl.SPEED_DIAL_MODE_PRESET_RIGHT = 2
WP_platterControl.SPEED_DIAL_MODE_PRESET_DOWN = 3

-- CYCLES MODES
WP_platterControl.CYCLES_MODE_SINGLE_CYCLE = 0 -- Single cycle in repeat (with pingpong option)
WP_platterControl.CYCLES_MODE_MULTI_CYCLES_UNLIMITED = 1 -- Endlessly Increasing cycles
WP_platterControl.CYCLES_MODE_MULTI_CYCLES_LIMITED = 2 -- Multiple cycles in repeat (with cycle count setting, with pingpong option, with randomize option)
WP_platterControl.CYCLES_MODE_MULTI_CYCLES_SEQUENCE = 3 -- Cycle sequence (with sequence numbers text, pingpong and randomize options)


function WP_platterControl:Name()
    return 'Platter Control'
end

function WP_platterControl:Version()
    return '1.3.0'
end

function WP_platterControl:UILabel()
    return 'Platter Control'
end

function WP_platterControl:Description()
    return 'Control Platter Speed by Bone Angle'
end

function WP_platterControl:Creator()
    return 'Maarten de Haas, Wigglepixel'
end

function WP_platterControl:ColorizeIcon()
    return false
end

-- **************************************************
-- Is Relevant / Is Enabled
-- **************************************************

function WP_platterControl:IsRelevant(moho)
    if (not _did_my_resources_package_path) then
        package.path = package.path..';'..moho:UserAppDir()..'/scripts/ScriptResources/?.lua;'
        _did_my_resources_package_path = true
    end

    return true
end

function WP_platterControl:IsEnabled(moho)
    return (moho.document:CurrentDocAction() == '' and moho.layer:IsBoneType())
end

-- **************************************************
-- Settings
-- **************************************************

WP_platterControl.rangeStartFr = nil
WP_platterControl.rangeEndFr = nil
WP_platterControl.frameInterval = 1
WP_platterControl.clearAllKeyframes = true -- REMOVE ALL KEYFRAMES ON PLATTER BONE ROTATION CHANNEL BEFORE WRITING NEW ONES

WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd = true
WP_platterControl.onlyWriteKeyWhenSpeedChanges = false
WP_platterControl.frameIntervalOffsetFrames = 0
WP_platterControl.startFrameIntervalAtTimelineStart = false

WP_platterControl.rpm = 10
WP_platterControl.ccwDir = false
WP_platterControl.showDoneConfirmation = true

WP_platterControl.speedBoneId = nil
WP_platterControl.platterBoneIds = nil

WP_platterControl.filterSpeedBonesListOnKeys = false

WP_platterControl.speedSteps = 0
WP_platterControl.rotationSteps = 0

WP_platterControl.useRealSpeedAngle = false -- TO USE fAngle (instead of fAnimAngle) for speed bone angle (is slower, but also works when speed bone is controlled by smart bone)

WP_platterControl.speedDialDirMode = WP_platterControl.SPEED_DIAL_MODE_PRESET_LEFT

WP_platterControl.cyclesMode = WP_platterControl.CYCLES_MODE_SINGLE_CYCLE

WP_platterControl.cyclesCount = 1
WP_platterControl.cyclesSequence = ''
WP_platterControl.cyclesPingpong = false

-- **************************************************
-- Preferences
-- **************************************************

function WP_platterControl:ResetPrefs()
    self.frameInterval = 1

    self.rpm = 10
    self.ccwDir = false
    self.showDoneConfirmation = true

    self.speedBoneId = nil
    self.speedBoneLabelCheck = nil

    self.platterBoneIds = nil -- ARRAY
    self.platterBoneLabelChecks = nil -- ARRAY

    self.alwaysWriteKeyAtRangeStartAndEnd = true
    self.onlyWriteKeyWhenSpeedChanges = false
    self.frameIntervalOffsetFrames = 0
    self.startFrameIntervalAtTimelineStart = false

    self.clearAllKeyframes = true

    self.filterSpeedBonesListOnKeys = false
    self.speedSteps = 0
    self.rotationSteps = 0

    WP_platterControl.useRealSpeedAngle = false

    WP_platterControl.speedDialDirMode = WP_platterControl.SPEED_DIAL_MODE_PRESET_LEFT
    WP_platterControl.cyclesMode = WP_platterControl.CYCLES_MODE_SINGLE_CYCLE
    WP_platterControl.cyclesCount = 1
    WP_platterControl.cyclesSequence = ''
    WP_platterControl.cyclesPingpong = false
end

function WP_platterControl:SavePrefs(prefs)
    self.rpm = prefs:GetFloat('WP_platterControl.rpm', 10.0)
    self.ccwDir = prefs:GetBool('WP_platterControl.ccwDir', false)
    self.showDoneConfirmation = prefs:GetBool('WP_platterControl.showDoneConfirmation', true)

    self.speedBoneId = prefs:GetInt('WP_platterControl.speedBoneId', nil)
    self.speedBoneLabelCheck = prefs:GetString('WP_platterControl.speedBoneLabelCheck', nil)

    self.platterBoneIds = prefs:GetString('WP_platterControl.platterBoneIds', nil)
    if (self.platterBoneIds ~= nil) then
        local platterBonesCount = WP_utils:arrLen(self.platterBoneIds)
        self.platterBoneIds = WP_utils:split(self.platterBoneIds, ',')
        for i = 1, platterBonesCount, 1 do
            self.platterBoneIds[i] = WP_utils:toInt(self.platterBoneIds[i])
        end
    end
    self.platterBoneLabelChecks = prefs:GetString('WP_platterControl.platterBoneLabelChecks', nil)
    if (self.platterBoneLabelChecks ~= nil) then
        self.platterBoneLabelChecks = WP_utils:split(self.platterBoneLabelChecks, WP_platterControl.STR_ARR_TO_CONCAT_STRING_DIVIDER)
    end

    self.frameInterval = prefs:GetInt('WP_platterControl.frameInterval', 1)

    self.alwaysWriteKeyAtRangeStartAndEnd = prefs:GetBool('WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd', true)
    self.onlyWriteKeyWhenSpeedChanges = prefs:GetBool('WP_platterControl.onlyWriteKeyWhenSpeedChanges', false)
    self.frameIntervalOffsetFrames = prefs:GetInt('WP_platterControl.frameIntervalOffsetFrames', 0)
    self.startFrameIntervalAtTimelineStart = prefs:GetBool('WP_platterControl.startFrameIntervalAtTimelineStart', false)

    self.clearAllKeyframes = prefs:GetBool('WP_platterControl.clearAllKeyframes', true)

    self.filterSpeedBonesListOnKeys = prefs:GetBool('WP_platterControl.filterSpeedBonesListOnKeys', false)
    self.speedSteps = prefs:GetInt('WP_platterControl.frameInterval', 1)
    self.rotationSteps = prefs:GetInt('WP_platterControl.frameInterval', 1)

    self.useRealSpeedAngle = prefs:GetBool('WP_platterControl.useRealSpeedAngle', false)

    self.speedDialDirMode = prefs:GetInt('WP_platterControl.speedDialDirMode', WP_platterControl.SPEED_DIAL_MODE_PRESET_LEFT)
    self.cyclesMode = prefs:GetInt('WP_platterControl.cyclesMode', WP_platterControl.CYCLES_MODE_SINGLE_CYCLE)
    self.cyclesCount = prefs:GetInt('WP_platterControl.cyclesCount', 1)
    self.cyclesSequence = prefs:GetString('WP_platterControl.cyclesSequence', '')
    self.cyclesPingpong = prefs:GetBool('WP_platterControl.cyclesPingpong', false)
end

function WP_platterControl:LoadPrefs(prefs)
    prefs:SetInt('WP_platterControl.rpm', self.rpm)
    prefs:SetBool('WP_platterControl.ccwDir', self.ccwDir)
    prefs:SetBool('WP_platterControl.showDoneConfirmation', self.showDoneConfirmation)

    prefs:SetInt('WP_platterControl.speedBoneId', self.speedBoneId)
    prefs:SetString('WP_platterControl.speedBoneLabelCheck', self.speedBoneLabelCheck)

    if (self.platterBoneIds == nil) then
        prefs:SetString('WP_platterControl.platterBoneIds', '')
    else
        prefs:SetString('WP_platterControl.platterBoneIds', table.concat(self.platterBoneIds, ','))
    end

    if (self.platterBoneLabelChecks == nil) then
        prefs:SetString('WP_platterControl.platterBoneLabelChecks', '')
    else
        prefs:SetString('WP_platterControl.platterBoneLabelChecks',
            table.concat(self.platterBoneLabelChecks, WP_platterControl.STR_ARR_TO_CONCAT_STRING_DIVIDER))
    end

    prefs:SetInt('WP_platterControl.frameInterval', self.frameInterval)

    prefs:SetBool('WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd', self.alwaysWriteKeyAtRangeStartAndEnd)
    prefs:SetBool('WP_platterControl.onlyWriteKeyWhenSpeedChanges', self.onlyWriteKeyWhenSpeedChanges)
    prefs:SetInt('WP_platterControl.frameIntervalOffsetFrames', self.frameIntervalOffsetFrames)
    prefs:SetBool('WP_platterControl.startFrameIntervalAtTimelineStart', self.startFrameIntervalAtTimelineStart)

    prefs:SetBool('WP_platterControl.clearAllKeyframes', self.clearAllKeyframes)

    prefs:SetBool('WP_platterControl.filterSpeedBonesListOnKeys', self.filterSpeedBonesListOnKeys)
    prefs:SetInt('WP_platterControl.speedSteps', self.speedSteps)
    prefs:SetInt('WP_platterControl.rotationSteps', self.rotationSteps)

    prefs:SetBool('WP_platterControl.useRealSpeedAngle', self.useRealSpeedAngle)

    prefs:SetInt('WP_platterControl.speedDialDirMode', self.speedDialDirMode)
    prefs:SetInt('WP_platterControl.cyclesMode', self.cyclesMode)
    prefs:SetInt('WP_platterControl.cyclesCount', self.cyclesCount)
    prefs:SetString('WP_platterControl.cyclesSequence', self.cyclesSequence)
    prefs:SetBool('WP_platterControl.cyclesPingpong', self.cyclesPingpong)
end

-- **************************************************
-- Dialog
-- **************************************************

WP_platterControlDialog = {}

WP_platterControlDialog.MSG_RESET = MOHO.MSG_BASE
WP_platterControlDialog.MSG_SHOW_DONE_CONFIRMATION = MOHO.MSG_BASE + 1

WP_platterControlDialog.MSG_RANGE_START_FR = MOHO.MSG_BASE + 2
WP_platterControlDialog.MSG_RANGE_END_FR = MOHO.MSG_BASE + 3
WP_platterControlDialog.MSG_CLEAR_ALL_KEYFRAMES = MOHO.MSG_BASE + 4

WP_platterControlDialog.MSG_FRAME_INTERVAL =  MOHO.MSG_BASE + 5
WP_platterControlDialog.MSG_START_FRAME_INTERVAL_AT_TIMELINE_START = MOHO.MSG_BASE + 6
WP_platterControlDialog.MSG_FRAME_INTERVAL_OFFSET_FRAMES =  MOHO.MSG_BASE + 7
WP_platterControlDialog.MSG_FRAME_INTERVAL_PRESET_1 =  MOHO.MSG_BASE + 8
WP_platterControlDialog.MSG_FRAME_INTERVAL_PRESET_2 = MOHO.MSG_BASE + 9
WP_platterControlDialog.MSG_FRAME_INTERVAL_PRESET_3 = MOHO.MSG_BASE + 10
WP_platterControlDialog.MSG_FRAME_INTERVAL_PRESET_4 = MOHO.MSG_BASE + 11

WP_platterControlDialog.MSG_RPM = MOHO.MSG_BASE + 12
WP_platterControlDialog.MSG_CYCLE_LENGTH = MOHO.MSG_BASE + 13

WP_platterControlDialog.MSG_USE_CCWS_DIRECTION = MOHO.MSG_BASE + 14

WP_platterControlDialog.MSG_RPM_PRESET_5 =  MOHO.MSG_BASE + 15
WP_platterControlDialog.MSG_RPM_PRESET_10 =  MOHO.MSG_BASE + 16
WP_platterControlDialog.MSG_RPM_PRESET_15 =  MOHO.MSG_BASE + 17
WP_platterControlDialog.MSG_RPM_PRESET_20 =  MOHO.MSG_BASE + 18
WP_platterControlDialog.MSG_RPM_PRESET_VINYL_LP = MOHO.MSG_BASE + 19
WP_platterControlDialog.MSG_RPM_PRESET_VINYL_SINGLE = MOHO.MSG_BASE + 20
WP_platterControlDialog.MSG_RPM_PRESET_COMP_CASSETTE = MOHO.MSG_BASE + 21

WP_platterControlDialog.MSG_SPEED_BONE = MOHO.MSG_BASE + 22
WP_platterControlDialog.MSG_PLATTER_BONE = MOHO.MSG_BASE + 23

WP_platterControlDialog.MSG_ONLY_WRITE_KEY_WHEN_SPEED_CHANGES = MOHO.MSG_BASE + 24
WP_platterControlDialog.MSG_ALWAYS_WRITE_KEY_AT_RANGE_START_AND_END = MOHO.MSG_BASE + 25

WP_platterControlDialog.MSG_FILTER_SPEED_BONES_LIST_ON_KEYS = MOHO.MSG_BASE + 26
WP_platterControlDialog.MSG_SPEED_STEPS = MOHO.MSG_BASE + 27
WP_platterControlDialog.MSG_ROTATION_STEPS = MOHO.MSG_BASE + 28

WP_platterControlDialog.MSG_USE_REAL_SPEED_ANGLE = MOHO.MSG_BASE + 29

WP_platterControlDialog.MSG_SPEED_DIAL_MODE_PRESET_LEFT = MOHO.MSG_BASE + 30
WP_platterControlDialog.MSG_SPEED_DIAL_MODE_PRESET_UP = MOHO.MSG_BASE + 31
WP_platterControlDialog.MSG_SPEED_DIAL_MODE_PRESET_RIGHT = MOHO.MSG_BASE + 32
WP_platterControlDialog.MSG_SPEED_DIAL_MODE_PRESET_DOWN = MOHO.MSG_BASE + 33

WP_platterControlDialog.MSG_CYCLES_MODE_SINGLE_CYCLE = MOHO.MSG_BASE + 34
WP_platterControlDialog.MSG_CYCLES_MODE_MULTI_CYCLES_LIMITED = MOHO.MSG_BASE + 35
WP_platterControlDialog.MSG_CYCLES_MODE_MULTI_CYCLES_UNLIMITED = MOHO.MSG_BASE + 36
WP_platterControlDialog.MSG_CYCLES_MODE_MULTI_CYCLES_SEQUENCE = MOHO.MSG_BASE + 37

WP_platterControlDialog.MSG_CYCLES_COUNT = MOHO.MSG_BASE + 38
WP_platterControlDialog.MSG_CYCLES_SEQUENCE = MOHO.MSG_BASE + 39
WP_platterControlDialog.MSG_CYCLES_PINGPONG = MOHO.MSG_BASE + 40

-- RETURN THE SELECTED OPTION (0-BASED) FROM THE BUTTONS IN THE SUPPLIED RADION LIST
function WP_platterControlDialog:GetSelectedIndexFromRadionList(radionList)
    for i, btn in ipairs(radionList) do
        if (btn:Value()) then return i - 1 end
    end
    return nil
end


function WP_platterControlDialog:fillBoneList(list, skeleton, filterOnKeys)
    local filterOnKeys = filterOnKeys or false
    local bone

    -- CLEAR LIST
    for i = list:CountItems() - 1, 0, -1 do
        list:RemoveItem(list:CountItems() - 1)
    end

    if (skeleton:CountBones() > 0) then
        list:AddItem('Select Bone')
    end

    for i = 0, skeleton:CountBones() - 1 do
        bone = skeleton:Bone(i)
        if (filterOnKeys == false or bone.fAnimAngle:CountKeys() > 1) then
            list:AddItem(bone:Name())
        else
            list:AddItem(WP_platterControl.HIDDEN_BONE_NAME)
        end
    end

    list:SetSelItem(list:GetItem(0), true, false)
end

function WP_platterControlDialog:new(mohoDoc, skeleton)
    local d = LM.GUI.SimpleDialog('Wigglepixel Platter Control v'..WP_platterControl:Version(), self)
    local l = d:GetLayout()

    d.mohoDoc = mohoDoc
    d.skeleton = skeleton

    -- RESET BUTTON
    d.btnReset = LM.GUI.Button('RESET SETTINGS', d.MSG_RESET)
    d.btnReset:SetToolTip('Reset platter control settings')
    l:AddChild(d.btnReset, LM.GUI.ALIGN_FILL, 0)

    -- ---------------------------------------------

    -- BONE LISTS
    l:PushH(LM.GUI.ALIGN_LEFT, 10)
        -- SPEED DIAL BONE
        l:PushV(LM.GUI.ALIGN_TOP, 5)
            l:PushH(LM.GUI.ALIGN_LEFT, 10)
                -- LABEL
                d.lblSpeedBone = LM.GUI.StaticText('SOURCE: Select Speed Dial Bone')
                d.lblSpeedBone:SetToolTip('Select Speed Dial Controller Bone which functions as the source to create the animation')
                l:AddChild(d.lblSpeedBone, LM.GUI.ALIGN_LEFT, 0)

                -- FILTER ON BONES WITH KEYS
                d.chkFilterSpeedBonesListOnKeys = LM.GUI.CheckBox('Reveal bones with keyframes', d.MSG_FILTER_SPEED_BONES_LIST_ON_KEYS)
                d.chkFilterSpeedBonesListOnKeys:SetToolTip('When checked only bones with keyframes will be listed')
                l:AddChild(d.chkFilterSpeedBonesListOnKeys, LM.GUI.ALIGN_LEFT, 0)
            l:Pop()

            -- SPEED STEPS
            d.txtSpeedSteps = LM.GUI.TextControl(50, '0', d.MSG_SPEED_STEPS, LM.GUI.FIELD_UINT, 'Speed steps')
            d.txtSpeedSteps:SetToolTip('Set higher than zero to set a stepped interval in speed values (0 = stepless)')
            -- l:AddChild(d.txtSpeedSteps, LM.GUI.ALIGN_LEFT, 0)

            -- LIST
            d.lstSpeedBone = LM.GUI.ImageTextList(400, 200, d.MSG_SPEED_BONE)
            l:AddChild(d.lstSpeedBone, LM.GUI.ALIGN_LEFT, 0)
        l:Pop()

        -- PLATTER BONE
        l:PushV(LM.GUI.ALIGN_TOP, 5)
            -- LABEL
            d.lblPlatterBone = LM.GUI.StaticText('TARGET: Select one or more Platter Bones')
            d.lblPlatterBone:SetToolTip('Select Platter Bone to which the rotation keyframes will be written to')
            l:AddChild(d.lblPlatterBone, LM.GUI.ALIGN_LEFT, 0)

            -- -- ROTATION STEPS
            d.txtRotationSteps = LM.GUI.TextControl(50, '0', d.MSG_ROTATION_STEPS, LM.GUI.FIELD_UINT, 'Rotation steps')
            d.txtRotationSteps:SetToolTip('Set higher than zero to set a stepped interval in rotation angle values (0 = stepless)')
            -- l:AddChild(d.txtRotationSteps, LM.GUI.ALIGN_LEFT, 0)

            -- LIST
            d.lstPlatterBone = LM.GUI.ImageTextList(400, 200, d.MSG_PLATTER_BONE)
            d.lstPlatterBone:SetAllowsMultipleSelection(true)
            l:AddChild(d.lstPlatterBone, LM.GUI.ALIGN_LEFT, 0)
        l:Pop()
    l:Pop()

    -- FILL BONE LISTS
    self:fillBoneList(d.lstSpeedBone, d.skeleton, WP_platterControl.filterSpeedBonesListOnKeys)
    self:fillBoneList(d.lstPlatterBone, d.skeleton, false)

    l:PushH(LM.GUI.ALIGN_LEFT, 20)
        -- SPEED DIAL BONE HEADER
        l:AddChild(LM.GUI.StaticText('SPEED DIAL BONE'), LM.GUI.ALIGN_LEFT, 0)

        -- SPEED DIAL MODE OPTIONS
        local speedDialDirModePresets = {
            { d.MSG_SPEED_DIAL_MODE_PRESET_LEFT, 'Left' },
            { d.MSG_SPEED_DIAL_MODE_PRESET_UP, 'Up' },
            { d.MSG_SPEED_DIAL_MODE_PRESET_RIGHT, 'Right' },
            { d.MSG_SPEED_DIAL_MODE_PRESET_DOWN, 'Down' },
        }
        l:PushH(LM.GUI.ALIGN_LEFT, 5)
            d.lblSpeedDialDirMode = LM.GUI.StaticText('Zero direction is')
            l:AddChild(d.lblSpeedDialDirMode, LM.GUI.ALIGN_LEFT, 0)
            d.radSpeedDialDirMode = {}
            for i, data in ipairs(speedDialDirModePresets) do
                d.radSpeedDialDirMode[i] = LM.GUI.RadioButton(data[2], data[1])
                d.radSpeedDialDirMode[i]:SetToolTip('Click to set speed dial direction to '..data[2])
                l:AddChild(d.radSpeedDialDirMode[i], LM.GUI.ALIGN_LEFT, 0)
            end
        l:Pop()
    l:Pop()

    -- DIVIDER
    l:AddChild(LM.GUI.Divider(false), LM.GUI.ALIGN_FILL)

    -- CYCLES HEADER
    l:AddChild(LM.GUI.StaticText('CYCLES'), LM.GUI.ALIGN_LEFT, 0)

    -- CYCLES MODE PRESET BUTTONS
    local cyclesModePresets = {
        { d.MSG_CYCLES_MODE_SINGLE_CYCLE, 'Single 360 deg Cycle on Repeat' },
        { d.MSG_CYCLES_MODE_MULTI_CYCLES_UNLIMITED, 'Unlimited Increasing Cycles' },
        { d.MSG_CYCLES_MODE_MULTI_CYCLES_LIMITED, 'Cycle Range on repeat' },
        -- { d.MSG_CYCLES_MODE_MULTI_CYCLES_SEQUENCE, 'Cycles Sequence on repeat' },
    }
    l:PushH(LM.GUI.ALIGN_LEFT, 5)
        -- CYCLE MODES
        d.radCyclesMode = {}
        d.lblCyclesMode = LM.GUI.StaticText('Mode')
        l:AddChild(d.lblCyclesMode, LM.GUI.ALIGN_LEFT, 0)
        for i, data in ipairs(cyclesModePresets) do
            d.radCyclesMode[i] = LM.GUI.RadioButton(data[2], data[1])
            d.radCyclesMode[i]:SetToolTip('Click to set Cycles Mode to '..data[2])
            l:AddChild(d.radCyclesMode[i], LM.GUI.ALIGN_LEFT, 0)
        end

        -- CYCLES COUNT
        d.txtCyclesCount = LM.GUI.TextControl(50, 0, d.MSG_CYCLES_MODE, LM.GUI.FIELD_UINT, 'Cycles')
        d.txtCyclesCount:SetToolTip('Amount of cycles to use in repeat')
        l:AddChild(d.txtCyclesCount, LM.GUI.ALIGN_LEFT, 0)
    l:Pop()

    -- RPM PRESET BUTTONS
    d.rpmPresets = {
        { d.MSG_RPM_PRESET_5, '5' },
        { d.MSG_RPM_PRESET_10, '10' },
        { d.MSG_RPM_PRESET_15, '15' },
        { d.MSG_RPM_PRESET_20, '20' },
        { d.MSG_RPM_PRESET_VINYL_LP, 'Vinyl LP' },
        { d.MSG_RPM_PRESET_VINYL_SINGLE, 'Vinyl Single' },
        { d.MSG_RPM_PRESET_COMP_CASSETTE, 'Compact Cassette' },
    }
    l:PushH(LM.GUI.ALIGN_LEFT, 5)
        local rpmTooltip = 'Revolutions Per Minute'
        local rpmPresetBtn

        l:AddChild(LM.GUI.StaticText('RPM'), LM.GUI.ALIGN_LEFT, 0)

        for _, data in ipairs(d.rpmPresets) do
            rpmPresetBtn = LM.GUI.Button(data[2], data[1])
            rpmPresetBtn:SetToolTip('Click to set rpm to '..data[2])
            l:AddChild(rpmPresetBtn, LM.GUI.ALIGN_LEFT, 0)
        end

        l:AddChild(LM.GUI.StaticText('-->'), LM.GUI.ALIGN_LEFT, 0)

        -- RPM
        d.txtRpm = LM.GUI.TextControl(50, '33', d.MSG_RPM, LM.GUI.FIELD_FLOAT, '')
        d.txtRpm:SetToolTip(rpmTooltip)
        l:AddChild(d.txtRpm, LM.GUI.ALIGN_LEFT, 0)

        d.txtCycleLength = LM.GUI.TextControl(180, 0, d.MSG_CYCLE_LENGTH, LM.GUI.FIELD_TEXT)
        l:AddChild(d.txtCycleLength, LM.GUI.ALIGN_LEFT, 0)
    l:Pop()

    -- COUNTER CLOCKWISE (INVERT DIRECTION)
    d.chkCcwDir = LM.GUI.CheckBox('Counter Clockwise (Invert Direction)', d.MSG_USE_CCWS_DIRECTION)
    d.chkCcwDir:SetToolTip('When checked forward movements will rotate counter clockwise (rotations inverted)')
    l:AddChild(d.chkCcwDir, LM.GUI.ALIGN_LEFT, 0)

    l:PushH(LM.GUI.ALIGN_LEFT, 20)
        -- CYCLES SEQUENCE
        d.txtCyclesSequence = LM.GUI.TextControl(150, 0, d.MSG_CYCLES_SEQUENCE, LM.GUI.FIELD_TEXT, 'Cycles Sequence')
        d.txtCyclesSequence:SetToolTip('Amount of cycles to use in repeat (1-based)')
        -- l:AddChild(d.txtCyclesSequence, LM.GUI.ALIGN_LEFT, 0)

        -- CYCLES PINGPONG
        d.chkCyclesPingPong = LM.GUI.CheckBox('Ping Pong', d.MSG_CYCLES_PINGPONG)
        d.chkCyclesPingPong:SetToolTip('When checked the cycle range will be played forward and backwards')
        -- l:AddChild(d.chkCyclesPingPong, LM.GUI.ALIGN_LEFT, 0)
    l:Pop()

    -- DIVIDER
    l:AddChild(LM.GUI.Divider(false), LM.GUI.ALIGN_FILL)

    -- KEYFRAMES HEADER
    l:AddChild(LM.GUI.StaticText('KEYFRAME WRITING'), LM.GUI.ALIGN_LEFT, 0)

    -- FRAME RANGE
    l:PushH(LM.GUI.ALIGN_LEFT, 20)
        l:AddChild(LM.GUI.StaticText('Range'), LM.GUI.ALIGN_LEFT, 0)
        -- START FRAME
        d.txtRangeStartFr = LM.GUI.TextControl(50, WP_utils:toInt(d.mohoDoc:StartFrame()), d.MSG_RANGE_START_FR, LM.GUI.FIELD_UINT, '')
        d.txtRangeStartFr:SetToolTip('Set the first frame for writing keyframes to the timeline')
        l:AddChild(d.txtRangeStartFr, LM.GUI.ALIGN_LEFT, 0)

        -- HYPHEN
        l:AddChild(LM.GUI.StaticText('-'), LM.GUI.ALIGN_CENTER, 0)

        -- END FRAME
        d.txtRangeEndFr = LM.GUI.TextControl(50, WP_utils:toInt(d.mohoDoc:EndFrame()), d.MSG_RANGE_END_FR, LM.GUI.FIELD_UINT, '')
        d.txtRangeEndFr:SetToolTip('Set the last frame for writing keyframes to the timeline')
        l:AddChild(d.txtRangeEndFr, LM.GUI.ALIGN_LEFT, 0)
    l:Pop()

    -- CLEAR ALL EXISTING KEYFRAMES ON PLATTER ROTATION CHANNEL
    d.chkClearAllKeyframes = LM.GUI.CheckBox('Start by clearing the Platter bone\'s Rotation channel of the complete timeline', d.MSG_CLEAR_ALL_KEYFRAMES)
    d.chkClearAllKeyframes:SetToolTip('When checked the rotation channel of the platter bone will be cleared from keyframes prior to writing the new keyframes')
    l:AddChild(d.chkClearAllKeyframes, LM.GUI.ALIGN_LEFT, 0)

    -- ONLY WRITE KEY WHEN SPEED CHANGES
    d.chkOnlyWriteKeyWhenSpeedChanges = LM.GUI.CheckBox('[experimental] Only write Keys if Speed Changes', d.MSG_ONLY_WRITE_KEY_WHEN_SPEED_CHANGES)
    d.chkOnlyWriteKeyWhenSpeedChanges:SetToolTip('Not fully tested yet! When checked keyframes will only be written to the platter bone on frames where the speed dial has a keyframe')
    l:AddChild(d.chkOnlyWriteKeyWhenSpeedChanges, LM.GUI.ALIGN_LEFT, 0)

    -- USE REAL SPEED ANGLE
    d.chkUseRealSpeedAngle = LM.GUI.CheckBox('Use Real Speed Bone Angle (Extremely slow baking, but NEEDED when Speed Bone is controlled by Smart or Controller Bone)', d.MSG_USE_REAL_SPEED_ANGLE)
    d.chkUseRealSpeedAngle:SetToolTip('Turning this on gives extremely slow baking compared to leaving this off, but it always uses the right angles of the speed bone so it\'s way safer.')
    l:AddChild(d.chkUseRealSpeedAngle, LM.GUI.ALIGN_LEFT, 0)

    -- ALWAYS WRITE KEY AT START AND END OF RANGE
    d.chkAlwaysWriteKeyAtRangeStartAndEnd = LM.GUI.CheckBox('Always write key on start and end of range', d.MSG_ALWAYS_WRITE_KEY_AT_RANGE_START_AND_END)
    d.chkAlwaysWriteKeyAtRangeStartAndEnd:SetToolTip('When checked at the start and end of the given frame range there will always be written a keyframe')
    l:AddChild(d.chkAlwaysWriteKeyAtRangeStartAndEnd, LM.GUI.ALIGN_LEFT, 0)

    -- INTERVAL HEADER
    l:AddChild(LM.GUI.StaticText('INTERVAL'), LM.GUI.ALIGN_LEFT, 0)

    -- FRAME INTERVAL PRESET BUTTONS
    d.frameIntervalPresets = {
        { d.MSG_FRAME_INTERVAL_PRESET_1, 'One\'s' },
        { d.MSG_FRAME_INTERVAL_PRESET_2, 'Two\'s' },
        { d.MSG_FRAME_INTERVAL_PRESET_3, 'Three\'s' },
        { d.MSG_FRAME_INTERVAL_PRESET_4, 'Four\'s' },
    }
    l:PushH(LM.GUI.ALIGN_LEFT, 5)
        local frameIntervalTooltip = 'Frame interval, when set to a value higher than 1 not every frame gets a keyframe'
        local frameIntervalPresetBtn
        for _, data in ipairs(d.frameIntervalPresets) do
            frameIntervalPresetBtn = LM.GUI.Button(data[2], data[1])
            frameIntervalPresetBtn:SetToolTip('Click to set interval to '..data[2])
            l:AddChild(frameIntervalPresetBtn, LM.GUI.ALIGN_LEFT, 0)
        end

        l:AddChild(LM.GUI.StaticText('-->'), LM.GUI.ALIGN_LEFT, 0)

        -- FRAME INTERVAL
        d.txtFrameInterval = LM.GUI.TextControl(50, '1', d.MSG_FRAME_INTERVAL, LM.GUI.FIELD_UINT, '')
        d.txtFrameInterval:SetToolTip(frameIntervalTooltip)
        l:AddChild(d.txtFrameInterval, LM.GUI.ALIGN_LEFT, 0)

        l:AddChild(LM.GUI.StaticText(' '), LM.GUI.ALIGN_LEFT, 0)

        -- FRAME INTERVAL OFFSET FRAMES
        d.txtFrameIntervalOffsetFrames = LM.GUI.TextControl(50, '0', d.MSG_FRAME_INTERVAL_OFFSET_FRAMES, LM.GUI.FIELD_UINT, 'Offset')
        d.txtFrameIntervalOffsetFrames:SetToolTip('Set to a value above 0 to give the frame interval an offset in frames')
        l:AddChild(d.txtFrameIntervalOffsetFrames, LM.GUI.ALIGN_LEFT, 0)

        l:AddChild(LM.GUI.StaticText(' '), LM.GUI.ALIGN_LEFT, 0)

        -- START FRAME INTERVAL AT START OF TIMELINE
        d.chkStartFrameIntervalAtTimelineStart = LM.GUI.CheckBox('Calculate Interval from TL Start', d.MSG_START_FRAME_INTERVAL_AT_TIMELINE_START)
        d.chkStartFrameIntervalAtTimelineStart:SetToolTip('When checked the start of the frame interval will be calculated from the start of the timeline (if unchecked the interval will start at the start of the given range)')
        l:AddChild(d.chkStartFrameIntervalAtTimelineStart, LM.GUI.ALIGN_LEFT, 0)
    l:Pop()

    -- ---------------------------------------------

    -- DIVIDER
    l:AddChild(LM.GUI.Divider(false), LM.GUI.ALIGN_FILL)

    -- MISC HEADER
    l:AddChild(LM.GUI.StaticText('MISC'), LM.GUI.ALIGN_LEFT, 0)

    -- SHOW DONE CONFIRMATION
    d.chkShowDoneConfirmation = LM.GUI.CheckBox('Show confirmation dialog when done', d.MSG_SHOW_DONE_CONFIRMATION)
    d.chkShowDoneConfirmation:SetToolTip('Uncheck to hide confirmation message after writing keyframes')
    l:AddChild(d.chkShowDoneConfirmation, LM.GUI.ALIGN_LEFT, 0)

    return d
end

function WP_platterControlDialog:getCycleLengthText(rpm)
    if (rpm ~= nil) then
        if (rpm > 0) then
            local totalInMin = 1 / rpm
            local totalInFrames = totalInMin * 60 * WP_platterControl.fps
            return 'One Cycle = '..WP_utils:toInt(totalInFrames)..' frames'
        else
            return ''
        end
    else
        return ''
    end
end

function WP_platterControlDialog:updateCyclesCountInputToMode(mode)
    -- VALUE
    if (mode == WP_platterControl.CYCLES_MODE_SINGLE_CYCLE
        or mode == WP_platterControl.CYCLES_MODE_MULTI_CYCLES_LIMITED) then
        self.txtCyclesCount:SetValue('1')
    else
        self.txtCyclesCount:SetValue('0')
    end

    -- ENABLE/DISABLE
    self.txtCyclesCount:Enable(mode == WP_platterControl.CYCLES_MODE_MULTI_CYCLES_LIMITED)
end

function WP_platterControlDialog:UpdateWidgets()
    -- RPM AND CYCLE LENGTH
    self.txtRpm:SetValue(WP_platterControl.rpm)
    self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(WP_platterControl.rpm))

    -- COUNTER CLOCKWISE
    self.chkCcwDir:SetValue(WP_platterControl.ccwDir)

    -- CLEAR ALL KEYFRAMES
    self.chkClearAllKeyframes:SetValue(WP_platterControl.clearAllKeyframes)

    -- FRAME RANGE
    self.txtRangeStartFr:SetValue(WP_utils:toInt(self.mohoDoc:StartFrame()))
    self.txtRangeEndFr:SetValue(WP_utils:toInt(self.mohoDoc:EndFrame()))

    -- FRAME INTERVAL
    self.chkStartFrameIntervalAtTimelineStart:SetValue(WP_platterControl.startFrameIntervalAtTimelineStart)
    self.txtFrameInterval:SetValue(WP_platterControl.frameInterval)
    self.txtFrameIntervalOffsetFrames:SetValue(WP_platterControl.frameIntervalOffsetFrames)

    -- SET SELECTED SPEED BONE
    local selItemLabelTmp
    local boneItemCount = self.lstSpeedBone:CountItems()
    if (WP_platterControl.speedBoneId ~= nil and WP_platterControl.speedBoneId < boneItemCount) then
        selItemLabelTmp = self.lstSpeedBone:GetItem(WP_platterControl.speedBoneId + 1)
        if (selItemLabelTmp == WP_platterControl.speedBoneLabelCheck) then
            self.lstSpeedBone:SetSelItem(selItemLabelTmp, true, false)
        else
            self.lstSpeedBone:SetSelItem(self.lstSpeedBone:GetItem(0), true, false)
        end
    else
        self.lstSpeedBone:SetSelItem(self.lstSpeedBone:GetItem(0), true, false)
    end


    -- RETURNS INDEX OF VALUE IN TABLE/ARRAY. RETURNS -1 IF NOT FOUND
    function indexOfValue(arr, value)
        local len = WP_utils:arrLen(arr)
        for i = 1, len, 1 do
            if (arr[i] == value) then
                return i
            end
        end

        return -1
    end

    -- SET SELECTED PLATTER BONE
    local hasSelection = false
    if (WP_platterControl.platterBoneIds ~= nil) then
        local platterBoneIdsCount = WP_utils:arrLen(WP_platterControl.platterBoneIds)
        for i = 1, platterBoneIdsCount, 1 do
            local boneId = WP_platterControl.platterBoneIds[i]
            if (boneId < boneItemCount) then
                selItemLabelTmp = self.lstPlatterBone:GetItem(boneId + 1)

                if (indexOfValue(WP_platterControl.platterBoneLabelChecks, selItemLabelTmp) ~= -1) then
                    self.lstPlatterBone:SetSelItem(selItemLabelTmp, true, hasSelection)
                    hasSelection = true;
                end
            end
        end
    end
    if (hasSelection == false) then
        self.lstPlatterBone:SetSelItem(self.lstPlatterBone:GetItem(0), true, false)
    end

    -- ONLY WRITE WHEN SPEED CHANGES
    self.chkOnlyWriteKeyWhenSpeedChanges:SetValue(WP_platterControl.onlyWriteKeyWhenSpeedChanges)

    -- ALWAYS WRITE KEY WHEN ON START OR END OF RANGE
    self.chkAlwaysWriteKeyAtRangeStartAndEnd:SetValue(WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd)

    -- SHOW DONE CONFIRMATION
    self.chkShowDoneConfirmation:SetValue(WP_platterControl.showDoneConfirmation)

    -- FILTER SPEED BONES LIST ON KEYFRAMES
    self.chkFilterSpeedBonesListOnKeys:SetValue(WP_platterControl.filterSpeedBonesListOnKeys)

    -- SPEED STEPS
    self.txtSpeedSteps:SetValue(WP_platterControl.speedSteps)

    -- ROTATION STEPS
    self.txtRotationSteps:SetValue(WP_platterControl.rotationSteps)

    -- USE REAL SPEED ANGLE
    self.chkUseRealSpeedAngle:SetValue(WP_platterControl.useRealSpeedAngle)

    -- SPEED DIAL DIRECTION MODE
    self.radSpeedDialDirMode[WP_platterControl.speedDialDirMode + 1]:SetValue(true)

    -- CYCLES MODE
    self.radCyclesMode[WP_platterControl.cyclesMode + 1]:SetValue(true)

    -- CYCLES COUNT
    self:updateCyclesCountInputToMode(WP_platterControl.cyclesMode)

    -- CYCLES SEQUENCE
    self.txtCyclesSequence:SetValue(WP_platterControl.cyclesSequence)

    -- CYCLES PINGPONG
    self.chkCyclesPingPong:SetValue(WP_platterControl.cyclesPingpong)
end

function WP_platterControlDialog:HandleMessage(msg)
    if msg == self.MSG_RESET then
        local resetAlert = WP_utils:alert('This will reset all Platter Control preferences, are you sure?', true)
        if (resetAlert == 1) then return nil end
        WP_platterControl:ResetPrefs()
        self:UpdateWidgets()


    elseif msg == self.MSG_CYCLES_MODE_SINGLE_CYCLE then
        self:updateCyclesCountInputToMode(WP_platterControl.CYCLES_MODE_SINGLE_CYCLE)
    elseif msg == self.MSG_CYCLES_MODE_MULTI_CYCLES_UNLIMITED then
        self:updateCyclesCountInputToMode(WP_platterControl.CYCLES_MODE_MULTI_CYCLES_UNLIMITED)
    elseif msg == self.MSG_CYCLES_MODE_MULTI_CYCLES_LIMITED then
        self:updateCyclesCountInputToMode(WP_platterControl.CYCLES_MODE_MULTI_CYCLES_LIMITED)
    elseif msg == self.MSG_CYCLES_MODE_MULTI_CYCLES_SEQUENCE then
        self:updateCyclesCountInputToMode(WP_platterControl.CYCLES_MODE_MULTI_CYCLES_SEQUENCE)

    -- HANDLE FRAME INTERVAL PRESET BUTTONS
    elseif msg == self.MSG_FRAME_INTERVAL_PRESET_1 then
        self.txtFrameInterval:SetValue('1')
    elseif msg == self.MSG_FRAME_INTERVAL_PRESET_2 then
        self.txtFrameInterval:SetValue('2')
    elseif msg == self.MSG_FRAME_INTERVAL_PRESET_3 then
        self.txtFrameInterval:SetValue('3')
    elseif msg == self.MSG_FRAME_INTERVAL_PRESET_4 then
        self.txtFrameInterval:SetValue('4')

    -- HANDLE RPM PRESET BUTTONS
    elseif msg == self.MSG_RPM_PRESET_5 then
        self.txtRpm:SetValue('5')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_10 then
        self.txtRpm:SetValue('10')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_15 then
        self.txtRpm:SetValue('15')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_20 then
        self.txtRpm:SetValue('20')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_VINYL_LP then
        self.txtRpm:SetValue('33.33')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_VINYL_SINGLE then
        self.txtRpm:SetValue('45')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))
    elseif msg == self.MSG_RPM_PRESET_COMP_CASSETTE then
        self.txtRpm:SetValue('56.25')
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))

    elseif msg == self.MSG_RPM then
        self.txtCycleLength:SetValue(WP_platterControlDialog:getCycleLengthText(self.txtRpm:FloatValue()))

    elseif msg == WP_platterControlDialog.MSG_FILTER_SPEED_BONES_LIST_ON_KEYS then
        self:fillBoneList(self.lstSpeedBone, self.skeleton, self.chkFilterSpeedBonesListOnKeys:Value())
    end
end

function WP_platterControlDialog:OnValidate()
    -- GET START AND END FRAME VALUES
    local rangeStartFr = self.txtRangeStartFr:IntValue();
    local rangeEndFr = self.txtRangeEndFr:IntValue();

    -- CHECK START RANGE FRAME
    if (rangeStartFr < self.mohoDoc:StartFrame()) then
        WP_utils:alert('Frame Range cannot be outside of Document/Render Start Frame ('..WP_utils:toInt(self.mohoDoc:StartFrame())..')')
        return false
    end

    -- CHECK END RANGE FRAME
    if (rangeEndFr > self.mohoDoc:EndFrame()) then
        WP_utils:alert('Frame Range cannot be outside of Document/Render End Frame ('..WP_utils:toInt(self.mohoDoc:EndFrame())..')')
        return false
    end

    -- CHECK FRAME RANGE
    if (rangeEndFr - rangeStartFr <= 0) then
        WP_utils:alert('There must be a frame range of at least 1 frame to be able to proceed')
        return false
    end

    -- CHECK FRAME INTERVAL VALUE
    local frameInterval = WP_utils:toInt(self.txtFrameInterval:IntValue())
    if (frameInterval < 1) then
        WP_utils:alert('Sub frames aren\'t supported. The Frame Interval must be 1 or higher')
        return false
    end

    -- CHECK SPEED BONE
    local speedBoneId = self.lstSpeedBone:SelItem()
    if (speedBoneId <= 0 or speedBoneId == nil) then
        WP_utils:alert('Please select a Speed Dial Bone')
        return false
    end

    -- CHECK PLATTER BONE(S)
    if (self.lstPlatterBone:NumSelectedItems() == 0
        or (self.lstPlatterBone:NumSelectedItems() == 1 and self.lstPlatterBone:IsItemSelected(0))) then
        WP_utils:alert('Please select one or more Platter Bones')
        return false
    end

    for i = 1, self.lstPlatterBone:CountItems(), 1 do
        if (self.lstPlatterBone:IsItemSelected(i) and speedBoneId == i) then
            WP_utils:alert('Platter bones may not be the same as the speed bone')
            return false
        end
    end

    -- CHECK FOR NOT SELECTED HIDDEN BONE
    local speedBoneLabel = self.lstSpeedBone:SelItemLabel()
    if (speedBoneLabel == WP_platterControl.HIDDEN_BONE_NAME) then
        WP_utils:alert('Please select a Speed Dial Bone')
        return false
    end

    -- CHECK SPEED AND PLATTER BONE MAY NOT BE THE SAME
    local platterBoneIdTmp
    for i = 1, self.lstPlatterBone:CountItems(), 1 do
        if (self.lstPlatterBone:IsItemSelected(i) and speedBoneId == i - 1) then
            WP_utils:alert('Speed Dial Bone and Platter Bone may not be the same')
            return false
        end
    end

    if (self.txtRpm:FloatValue() == 0) then
        WP_utils:alert('Please set a RPM other than zero')
        return false
    end

    -- CHECK SEQUENCE
    local selectedCyclesMode = WP_platterControlDialog:GetSelectedIndexFromRadionList(self.radCyclesMode)
    if (selectedCyclesMode == WP_platterControl.CYCLES_MODE_MULTI_CYCLES_SEQUENCE) then
        local seqParts = WP_utils:split(self.txtCyclesSequence:Value(), ',')
        for i, part in ipairs(seqParts) do
            local partNumber = tonumber(part)
            if (partNumber == nil) then
                WP_utils:alert('Cycles sequence may only contain numbers')
                return false
            else
                partNumber = WP_utils:toInt(partNumber)
                if (partNumber < 1) then
                    WP_utils:alert('Cycles sequence may only contain positive numbers of 1 and up (1-based)')
                    return false
                end
            end
        end
    end

    return true
end

function WP_platterControlDialog:OnOK(moho)
    WP_platterControl.rangeStartFr = self.txtRangeStartFr:IntValue()
    WP_platterControl.rangeEndFr = self.txtRangeEndFr:IntValue()
    WP_platterControl.frameInterval = WP_utils:toInt(self.txtFrameInterval:IntValue())

    WP_platterControl.rpm = self.txtRpm:FloatValue()
    WP_platterControl.ccwDir = self.chkCcwDir:Value()

    WP_platterControl.speedBoneId = self.lstSpeedBone:SelItem() - 1
    WP_platterControl.speedBoneLabelCheck = self.lstSpeedBone:SelItemLabel()


    WP_platterControl.platterBoneIds = nil
    WP_platterControl.platterBoneLabelChecks = nil
    if (self.lstPlatterBone:NumSelectedItems() > 0) then
        for i = 1, self.lstPlatterBone:CountItems(), 1 do
            if (self.lstPlatterBone:IsItemSelected(i)) then
                if (WP_platterControl.platterBoneIds == nil) then
                    WP_platterControl.platterBoneIds = {}
                    WP_platterControl.platterBoneLabelChecks = {}
                end
                table.insert(WP_platterControl.platterBoneIds, i - 1)
                table.insert(WP_platterControl.platterBoneLabelChecks, self.lstPlatterBone:GetItem(i))
            end
        end
    end

    WP_platterControl.clearAllKeyframes = self.chkClearAllKeyframes:Value()

    WP_platterControl.showDoneConfirmation = self.chkShowDoneConfirmation:Value()

    WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd = self.chkAlwaysWriteKeyAtRangeStartAndEnd:Value()
    WP_platterControl.onlyWriteKeyWhenSpeedChanges = self.chkOnlyWriteKeyWhenSpeedChanges:Value()
    WP_platterControl.frameIntervalOffsetFrames = self.txtFrameIntervalOffsetFrames:IntValue()
    WP_platterControl.startFrameIntervalAtTimelineStart = self.chkStartFrameIntervalAtTimelineStart:Value()

    WP_platterControl.filterSpeedBonesListOnKeys = self.chkFilterSpeedBonesListOnKeys:Value()
    WP_platterControl.speedSteps = self.txtSpeedSteps:IntValue()
    WP_platterControl.rotationSteps = self.txtRotationSteps:IntValue()

    WP_platterControl.useRealSpeedAngle = self.chkUseRealSpeedAngle:Value()

    -- GET SELECTED SPEED DIAL DIRECTION MODE
    WP_platterControl.speedDialDirMode = WP_platterControlDialog:GetSelectedIndexFromRadionList(self.radSpeedDialDirMode)

    -- GET CYCLES MODE
    WP_platterControl.cyclesMode = WP_platterControlDialog:GetSelectedIndexFromRadionList(self.radCyclesMode)

    -- CYCLES COUNT
    WP_platterControl.cyclesCount = WP_utils:toInt(self.txtCyclesCount:IntValue())

    -- GET CYCLES SEQUENCE
    WP_platterControl.cyclesSequence = self.txtCyclesSequence:Value()

    WP_platterControl.cyclesPingpong = self.chkCyclesPingPong:Value()
end

-- **************************************************
-- GO
-- **************************************************

function WP_platterControl:Run(moho)
    -- CHECK UTILS VERSION
    local utilsVersionCheck = WP_utils:compareVersion(WP_platterControl.WP_UTILS_REQUIRED_VERSION, WP_utils.VERSION)
    if (utilsVersionCheck < 0) then
        WP_utils:alert("Halted. This script requires at least version "..WP_utils:ver2str(WP_platterControl.WP_UTILS_REQUIRED_VERSION).." of 'wp_utils'")
        return
    elseif (utilsVersionCheck > 0) then
        WP_utils:alert("Caution! This loaded 'wp_utils' is newer than the expected version "..WP_utils:ver2str(WP_platterControl.WP_UTILS_REQUIRED_VERSION))
    end

    local mohoDoc = moho.document

    -- GET VALUES
    WP_platterControl.fps = mohoDoc:Fps()
    local docStartFrame = mohoDoc:StartFrame()
    local docEndFrame = mohoDoc:EndFrame()

    -- GET BONE LAYER
    local boneLayer = mohoDoc:GetSelectedLayer()
    boneLayer = moho:LayerAsBone(boneLayer)
    local skeleton = boneLayer:Skeleton()

    -- OPEN DIALOG
    local d = WP_platterControlDialog:new(mohoDoc, skeleton)
    if (d:DoModal() == LM.GUI.MSG_CANCEL) then return end

    -- CALC ROT PER FRAME
    local rotPerFr = self.rpm / (60 * WP_platterControl.fps)

    -- GET SPEED BONE DIAL RANGE
    if (WP_platterControl.speedDialDirMode == WP_platterControl.SPEED_DIAL_MODE_PRESET_LEFT) then
        speedBoneAngleRangeDeg = { from = 180, to = 90 }
    elseif (WP_platterControl.speedDialDirMode == WP_platterControl.SPEED_DIAL_MODE_PRESET_UP) then
        speedBoneAngleRangeDeg = { from = 90, to = 0 }
    elseif (WP_platterControl.speedDialDirMode == WP_platterControl.SPEED_DIAL_MODE_PRESET_RIGHT) then
        speedBoneAngleRangeDeg = { from = 0, to = -90 }
    elseif (WP_platterControl.speedDialDirMode == WP_platterControl.SPEED_DIAL_MODE_PRESET_DOWN) then
        speedBoneAngleRangeDeg = { from = 270, to = 180 }
    end

    -- GET BONES
    local speedBone = skeleton:Bone(WP_platterControl.speedBoneId)
    local platterBones = {}
    local platterBonesCount = 0
    if (WP_platterControl.platterBoneIds ~= nil) then
        platterBonesCount = WP_utils:arrLen(WP_platterControl.platterBoneIds)

        for i = 1, platterBonesCount, 1 do
            table.insert(platterBones, skeleton:Bone(WP_platterControl.platterBoneIds[i]))
        end
    end

    -- CHECK IF SPEED BONE HAS ROTATION KEYFRAMES
    if (speedBone.fAnimAngle:CountKeys() == 0) then
        local resetAlert = WP_utils:alert('The speed bone you selected doesn\'t have any keyframes on the rotation channel. Are you sure you want to continue?', true)
        if (resetAlert == 1) then return nil end
    end

    -- PREP MULTI UNDO AND RAISE DIRTY FLAG
    mohoDoc:PrepMultiUndo()
    mohoDoc:SetDirty()

    local platterBonesCount = WP_utils:arrLen(platterBones)


    -- WRITE KEYFRAMES
    local dir
    if (WP_platterControl.ccwDir == true) then dir = 1 else dir = -1 end
    local speed
    local speedAngleDeg
    local speedRangeDeg = speedBoneAngleRangeDeg.to - speedBoneAngleRangeDeg.from

    local platterBoneRotRad = {}
    for i = 1, platterBonesCount, 1 do
        platterBoneRotRad[i] = platterBones[i].fAnimAngle:GetValue(docStartFrame)
    end

    local isIntervalFrame
    local isFrameToWrite
    local prevSpeed = nil
    local inRangeFr = 0 -- FRAME NUMBER FROM START OF RANGE (1-BASED, LIKE MOHO'S TIMELINE FRAME NUMBER)

    local calcStartFrame = docStartFrame
    local calcEndFrame = math.min(docEndFrame, WP_platterControl.rangeEndFr)


    -- CLEAR KEYFRAMES BEFORE WRITING NEW ONES
    if (WP_platterControl.clearAllKeyframes) then
        -- CLEAR COMPLETE TIMELINE ON THE PLATTER BONES' ANGLE CHANNELS
        for i = 1, platterBonesCount, 1 do
            platterBones[i].fAnimAngle:Clear()
        end
    else
        -- ONLY CLEAR RANGE ON THE PLATTER BONES' ANGLE CHANNELS
        for i = 1, platterBonesCount, 1 do
            for fr = WP_platterControl.rangeStartFr, WP_platterControl.rangeEndFr, 1 do
                platterBones[i].fAnimAngle:DeleteKey(fr)
            end
        end
    end

    for fr = calcStartFrame, calcEndFrame, 1 do
        if (WP_platterControl.useRealSpeedAngle) then
            -- GET ACTUAL ANGLE VALUE (ALSO WORKS WHEN SPEED BONE IS SET BY SMART BONE ACTION OR CONTROLLER BONE, BUT IS SUPER SLOW)
            moho:SetCurFrame(fr)
            speedAngleDeg = WP_utils:rad2deg(speedBone.fAngle)
        else
            -- GET ANIMATION VALUE FOR SPEED BONE (DOESN'T WORK WHEN IS CONTROLLED BY SMART BONE ACTION OR CONTROLER BONE, BUT IS SUPER FAST)
            speedAngleDeg = WP_utils:rad2deg(speedBone.fAnimAngle:GetValue(fr))
        end

        -- CALC SPEED
        speed = (speedAngleDeg - speedBoneAngleRangeDeg.from) / speedRangeDeg

        -- CALCULATE AND SET PLATTER ROTATION
        for i = 1, platterBonesCount, 1 do
            -- CALC NEW ANGLE
            platterBoneRotRad[i] = platterBoneRotRad[i] + (2 * math.pi * rotPerFr * dir * speed);

            -- LIMIT ANGLE TO CYCLES
            if (WP_platterControl.cyclesMode == WP_platterControl.CYCLES_MODE_SINGLE_CYCLE
                or WP_platterControl.cyclesMode == WP_platterControl.CYCLES_MODE_MULTI_CYCLES_LIMITED) then
                platterBoneRotRad[i] = math.fmod(platterBoneRotRad[i], 2 * math.pi * WP_platterControl.cyclesCount)

                if (platterBoneRotRad[i] > 0) then
                    platterBoneRotRad[i] = -2 * math.pi * WP_platterControl.cyclesCount + platterBoneRotRad[i]
                end
            end
        end

        -- SHOULD KEY BE WRITTEN?
        if (fr >= WP_platterControl.rangeStartFr and fr <= WP_platterControl.rangeEndFr) then
            -- INCREASE FRAME INDEX IN RANGE
            inRangeFr = inRangeFr + 1

            if (WP_platterControl.alwaysWriteKeyAtRangeStartAndEnd and
                (fr == WP_platterControl.rangeStartFr or fr == WP_platterControl.rangeEndFr)) then
                isFrameToWrite = true
            else
                if (WP_platterControl.onlyWriteKeyWhenSpeedChanges) then
                    -- CHECK IF CURRENT POSITION IS ON KEYFRAME FRAME OF SPEED BONE
                    isFrameToWrite = prevSpeed == nil or speed ~= prevSpeed
                else
                    -- ONLY WRITE KEYFRAME WHEN ON INTERVAL
                    local frForInterval
                    if (WP_platterControl.startFrameIntervalAtTimelineStart) then
                        frForInterval = fr
                    else
                        frForInterval = inRangeFr
                    end
                    isIntervalFrame = math.fmod(
                        frForInterval - 1 - WP_platterControl.frameIntervalOffsetFrames,
                        WP_platterControl.frameInterval) == 0
                    isFrameToWrite = isIntervalFrame
                end
            end
        else
            isFrameToWrite = false
        end

        -- WRITE KEYFRAME
        if (isFrameToWrite) then
            for i = 1, platterBonesCount, 1 do
                platterBones[i].fAnimAngle:SetValue(fr, platterBoneRotRad[i])

                if (WP_platterControl.frameInterval == 1) then
                    platterBones[i].fAnimAngle:SetKeyInterp(fr, MOHO.INTERP_SMOOTH)
                else
                    platterBones[i].fAnimAngle:SetKeyInterp(fr, MOHO.INTERP_STEP)
                end
            end
        end

        prevSpeed = speed
    end

    -- GO TO START
    moho:SetCurFrame(0)

    -- END
    if (WP_platterControl.showDoneConfirmation) then
        WP_utils:alert('Done!')
    end
end

Icon
WP Platter Control
Listed

Script type: Tool

Uploaded: Dec 07 2023, 18:59

Last modified: Jan 02 2024, 07:46

Animate (rotational) Bone Speeds with Accerelation/Deceleration with ease by Keyframing Speed instead of Angles.
DEMO/TUTORIAL
Short tutorial video on how to use the script (older version, but the basics are still the same) to generate rotating animations in Moho 14:





EXAMPLES

Example of an animation using the script to control a Reel to Reel tape deck (including controling smart bones controlling cut tapes on the tape passing the play head):



----------------------------------------------------------------------------------------------------------
VERSION HISTORY
----------------------------------------------------------------------------------------------------------

1.3.0 [current]
- Added: New mode added: 'Cycle Range on repeat'. This new mode makes it possible to repeat over more than a single cycle (360 degrees) of the platterbone while still being limited to the amount of cycles entered in the 'Cycles' field (so platter bone angle won't unlimitedly increase forever but stays within cycles range). This way you can for example have a platter bone with a smart bone action ranging from 0 to -720 degrees (= 2 cycles of 360 degrees each) in which you can do all kinds of things, like letting a character walk forwards from 0 to -360 degrees and backwards from -360 to -719 degrees to create things like pingpong loops. So in this example; when the speed is 100% the first cycle will show the character walking forward and the second cycle will be the character walking backwards and after that repeats that sequence forever while the speed is 100% (or plays it accordingly to a different speed value or even in reverse when speed is -100% ???? ).
- Added: You can now apply speed changes to multiple platter bones at once using multi select in the platter bones list
- Added/Changed: The frame range that will be written in the rotation channels of platter bones will now be cleared from keyframes before starting writing new ones so there's never a left over keyframe there before writing new ones
- Changed: On higher frame intervals written keys now get the 'Step' interpolation. On one's these will now always be set to 'smooth'. So keyframes now explicitely gets set to an interpolation type.

1.2.0
- Added: Speed Dial Bone Zero Speed direction setting. With this setting you can set what direction of that bone is representing the zero speed value. You can choose between Left [default, and as before], Up, Right or Down
- Added: Introducing Cycles modes. Currently there are two modes: one to render only single 360 degrees cycles on repeat and one to keep increasing angles to go over a single cycle. This also replaces the old 'Keep angles within 360 degrees' checkbox.
- Improved: Now stops calculating when end of set range is reached. So you don't need to wait 'till the end of the timeline no more.
- Changed: Added 'Experimental' to 'Only write Keys if Speed changes' because it's not fully tested and not sure if it's a valid option
- Added: more RPM presets
- Added: Show how many frames a cycle takes when choosing an RPM
- Fixed: some little issues
- Improved: Better dialog window layout

1.1.3
- Changed: Made compatible with newer utils file

1.1.2
- Added/Fixed: Moho doesn't always return the real value of the bone's angle. The script couldn't get the speed angle's value when the speed bone was controlled by a smart bone action causing the speed to never change and the script to fail.
To work around this/fix the script could use another method to get the REAL angle values of the speed bone, but that method is extremely slow. The difference is literally between the blink of an eye and waiting seconds to minutes for the baking to finish.
So I added a setting to enable this way slower, but always working, baking method. By default it's turned off (= speed baking), but when you are controlling the speed bone not by it's own keyframes, but from within a smart bone action (or controller bone), than you need to turn this setting on, otherwise the script won't work as expected.
- Changed/Fixed: Removed the setting to only write keys on frames where the speed bone dial had a keyframe. This turned out to not be very accurate/useful. Replaced by a new setting to only write keys when the speed actually changes.
- Removed: The experimental 'never repeat the same keyframe' turned out to be not useful. This was meant to optimize keyframe writing, but it's missing important features and caused issues so removed this for now in favour of real optimizations later.
- Changed: The speed bone list isn't by default filtered on only bones with keyframes any longer. Because bones controlled by smart bones don't have keyframes themselves, but are still great speed bones to use with the script. You can still the filtering on if you like.
- Added: Now shows the script version number in the dialog box title

1.1.1
- Changed: Now by default the script limits writing rotation values to a 360 degrees range. This fixes an issue where smart bone actions only ran the first 360 degrees cycle and weren't doing anything anymore after passing the (minus) 360 degrees range.
- Added: Checkbox to turn off the 360 degrees limitation (advised to never turn this off though, unless you know what you're doing and really need to go beyond 360!)
- Added: Shows script version number in dialog title.

1.1.0
- Initial release of script


----------------------------------------------------------------------------------------------------------
HOW TO INSTALL?
----------------------------------------------------------------------------------------------------------


How to install the script in Moho?
* Click on the 'Download for Install Script command''-button below to download the zip-file
* Unzip the downloaded file
* Start Moho
* Open the Scripts menu --> choose 'Install Script' --> Browse through the unziped folder and choose the folder named 'wp_platter_control_for-moho-install-script-command' --> It should now install the script
* Once installation is done Moho shows a dialog saying 'Installation Complete!'
* Restart Moho (only after restarting the tool will become available)
* You should now see the tool in the tools panel. Ready to use!


----------------------------------------------------------------------------------------------------------
ABOUT THE CURRENT VERSION
----------------------------------------------------------------------------------------------------------

Why I created this script?
For the quick music video's I create for my music on YouTube I often struggled with the limitations of both Moho Pro and Spine Pro in relation to rotating elements because both are focussed on animating from pose to pose, but and aren't very convenient to animate cycling rotations which need to be able to accelerate, decelerate, change speed, keep running 'endlessly' at an exact speed when at full speed (given by RPM) and also do all this scalable and quickly without any manual calculations and complexity.

To create this kind of animations we need to animate speed, instead of angles. But Moho cannot animate speed, but only angles. So that's why this script converts angles to speed to make all this very easy now for everything that needs to gradually speed up, slow down and reverse! And with just four keyframes on the speed dial bone we can now create a record speeding up, run for some minutes and slow down to a stop at the end, within seconds to minutes instead of hours (and a lot of headaches)!

Especially because Moho has the great feature of Smart Bones, with this script we can now even accelerate and decelerated looped action timelines of smart bones!

The possibilities are as endless as the cycling platters. It's now easy to animate all kinds of elements that need to change speed, like windmills, records players, tape decks, car wheels, airplane propellors, walkcycles, car traffic on a high way etc. Just to name a few.


What does the script do?
The scripts generates keyframes on the rotation channel of a (platter) bone to make it rotate at a certain speed by reading the animated angle from a Speed Dial bone. Basically it's converting an angle of a bone to rotating cycles with speed on another bone. Like this it's possible to quickly and painlessly generate rotating cycles with acceleration, deceleration etc.


How to use the script?

This is a new script! Backup your moho file before use just to be sure!
Please watch the short tutorial video above for more instructions.


Quick start
1. Make sure that you have a bone that needs to rotate using this script (the 'platter bone' ).

2. Add a bone to use as speed dial. Caution! While Moho works with counter clockwise angles, this script intentionally works differently and uses clockwise to make things a little easier compared to the real world:
- Point the bone to the left (the bone angle in moho is 180 degrees) for zero speed
- Point the bone up (the bone angle in Moho is 90 degrees) for full forward speed (100%)
- Point the bone down (the bone angle in Moho is 270 degrees) for full reversed speed (-100%)

(Since version 1.2.0 it's also possible to have the zero speed bone direction pointing up, right or down by a new setting in the script, but having zero speed when the bone is pointing to the left is the default)

3. Make sure this speed dial bone is not influencing any image or vector layer when rotating:
- Go to frame 0 (setup frame)
- Select the bone layer and the speed dial bone
- Select the bone strength tool (S on your keyboard)
- Change the Bone strength of the bone to 0 in the tool properties

4. Animate the rotation of this speed dial bone to change the speed over time (see above for the range).

5. Open the WP Platter Control Tool. Please watch the short tutorial video for more instructions, but the core part is:
- Select the speed bone in the list on the left 
- Select the platter bone in the list on the right
- Choose a cycle speed (RPM), or leave it at the default
- Hit OK to generate. The script will now look from frame to frame at the angle of the speed dial bone you just animated, and generate keyframes on the platter bone's rotation channel accordingly to convert this angle into platter speed.

6. Play the timeline!


Advanced: About creating a smart bone action to control with this script
This script only generates rotation cycles with speeds, acceleration and deceleration. But when using this to control a smart bone action you can basically control every other movement and complete timelines even! This makes the platter script even more powerful.

When you like to control a smart bone action with the script make sure your smart bone rotates CLOCKWISE. Meaning that while setting up the smart bone timeline the smart bone needs to rotate from 0 degrees to minus (!!) 359 degrees. That's considered forward by the script and is called a single cycle.

Even if you like to have that bone rotate counter clockwise when controlling it later, it's important to set it up like this. You can always turn on 'Counter Clockwise' when running the script to reverse direction!

[update since v1.3.0]
From v1.3.0 unwards it's possible to let the smart bone action contain more than a single cycle of 360 degrees. When you switch the mode of the Platter Control to 'Cycle Range on repeat' you can set a higher amount of cycles (360 degrees each) you want to use in the smart bone action. For instance; let's say you want to let a character walk from left to right in the first cycle (0 to -359 degrees on the smartbone) and than back to left again (-360 to -719 degrees on the smartbone) you can do that now by switching to that mode and setting the amount of Cycles to 2. 

The difference of this mode compared to the 'Unlimited Increasing Cycles' mode is that this mode has a limited amount of cycles (and therefor limited agrees on the platter bone) while the 'Unlimited Increasing Cycles' mode doesn't have any limit and keeps increasing degrees 'forever', never looping.


-----------------------------------------------------------------------------------------------------------
KNOWN LIMITATIONS AND WORKAROUNDS
-----------------------------------------------------------------------------------------------------------

Speed bones that are controlled by Smart Bone Actions or Controller bones gives unexpected results
Moho doesn't always return the real value of the bone's angle. The script can't by default get the speed angle's value when the speed bone is controlled by a smart bone action causing the speed to never change and the script to fail.
Since version 1.1.2 there's a setting to fix this. It's making the baking extremely slow unfortunately, but it get's the real angle value of the speed dial and so also works in scenarios where the speed dial doesn't have keyframes itself, but is controlled from somewhere else.
Just turn on the setting 'Use Real Speed Bone Angle' under 'Keyframe writing' and grab a cup of coffee while the baking is working...


Supported Moho version
Tested in Moho 14.1
This script, and all other scripts on this site are distributed as free software under the GNU General Public License 3.0 or later.
Downloads count: 351