Image
-- **************************************************
-- Provide Moho with the name of this script object
-- **************************************************

ScriptName = "HS_Hypotrochoid"

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

HS_Hypotrochoid = {}


function HS_Hypotrochoid:Name()
	return "Spirograph"
end

function HS_Hypotrochoid:Version()
	return "1.03a"
end

function HS_Hypotrochoid:IsBeginnerScript()
	return false
end

function HS_Hypotrochoid:Description()
	return "Hypotrochoids (Spirograph)"
end


function HS_Hypotrochoid:Creator()
	return "Hayasidist"
end

function HS_Hypotrochoid:UILabel()
	return HS_Hypotrochoid:Name() .. " Vers: " .. HS_Hypotrochoid:Version()
end


function HS_Hypotrochoid:ColorizeIcon()
	return true
end


--******************************
-- local utility subroutines
-- *****************************


-- ************************************************************
-- Globals for use in the UI -- the values here are irrelevant
--
-- ************************************************************


HS_Hypotrochoid.OuterCircle = 0 
HS_Hypotrochoid.InnerCircle = 0 
HS_Hypotrochoid.Offset = 0 
HS_Hypotrochoid.Cusps = 1



--******************************
-- prefs 
-- *****************************


function HS_Hypotrochoid:LoadPrefs(prefs)

	self.OuterCircle = prefs:GetFloat("HS_Hypotrochoid.OuterCircle", 1)
	self.InnerCircle = prefs:GetFloat("HS_Hypotrochoid.InnerCircle", .5)
	self.Offset = prefs:GetFloat("HS_Hypotrochoid.Offset", .5)
	self.Cusps = prefs:GetFloat("HS_Hypotrochoid.Cusps", 1)
end


function HS_Hypotrochoid:SavePrefs(prefs)

	prefs:SetFloat("HS_Hypotrochoid.OuterCircle", self.OuterCircle)
	prefs:SetFloat("HS_Hypotrochoid.InnerCircle", self.InnerCircle)
	prefs:SetFloat("HS_Hypotrochoid.Offset", self.Offset)
	prefs:SetFloat("HS_Hypotrochoid.Cusps", self.Cusps)

end

function HS_Hypotrochoid:ResetPrefs()

	self.OuterCircle = 1
	self.InnerCircle = .5
	self.Offset = .5
	self.Cusps = 1
end


-- **************************************************
-- Recurring values
--
-- **************************************************


local thisUUT = "B"

-- **************************************************
-- Drawing controls for the current shape
--
-- **************************************************

HS_Hypotrochoid.startPoint = 0
HS_Hypotrochoid.drawnPoints = 0
HS_Hypotrochoid.startVec = LM.Vector2:new_local()
HS_Hypotrochoid.endVec = LM.Vector2:new_local()

HS_Hypotrochoid.activeLayer = nil		-- which layer did we last write the points on?


--******************************
-- global utility subroutines
-- *****************************






-- **************************************************
-- The guts of this script
-- **************************************************

--
--  	relevant / enabled??
--

function HS_Hypotrochoid:IsEnabled(moho)
	local mesh =  moho:DrawingMesh()
	if (mesh == nil) then
		return false
	end
	if (moho.drawingLayer:CurrentAction() ~= "") then
		return false -- creating new objects in the middle of an action can lead to unexpected results
	end

	return true
end


function HS_Hypotrochoid:IsRelevant(moho)
	local rtx = false
	local vectorLayer = moho:LayerAsVector(moho.drawingLayer)
	local MohoVers
	if type (MOHO.ScriptInterface.AppVersion) == "function" then
		MohoVers = tonumber((string.gsub(moho:AppVersion(), "^(%d+)(%.%d+)(%..+)", "%1%2")))
	else
		MohoVers = 0
	end
	if MohoVers < 12 then 
		rtx = false
	elseif (moho:DisableDrawingTools()) then
		rtx = false
	elseif (vectorLayer) then
		rtx = true
	end
	return rtx
end





local function makeLoop (moho, points)
	local mesh =  moho:DrawingMesh()
	local n = mesh:CountPoints()
	mesh:SelectNone()
	local vec = LM.Vector2:new_local()
	local curv = .30
	vec:Set(0,0)

	mesh:AddLonePoint(vec, 0)
	for i = 0, points-1 do
		mesh:AppendPoint(vec, 0)
	end

	mesh:WeldPoints(n + points, n, 0)

	for i = 0, points-1 do
		mesh:Point(n+i):SetCurvature(curv, 0)
	end

	mesh:SelectConnected()

	local shapeID = moho:CreateShape(true, false, 0)
	if (shapeID >= 0) then
		mesh:Shape(shapeID).fHasOutline = true
	end
	HS_Hypotrochoid.startPoint = n
end



function HS_Hypotrochoid:Draw(moho)

	if self.activeLayer ~= moho.drawingLayer then
		return false
	end


	local i, j, s, y

	moho.document:PrepUndo(moho.drawingLayer)
	local drawingFrame = moho.drawingFrame

	local ptX = {}
	local ptY = {}
	local theta = 0
	local step = 2*math.pi/self.drawnPoints

	self.InnerCircle = self.OuterCircle / self.Cusps

-- (a-b)*COS(I60)+h*(COS(((a-b)/b)*I60))
-- (a-b)*SIN(I60)-h*(SIN(((a-b)/b)*I60))

	for i = 0, self.drawnPoints - 1 do
		ptX[i] = (self.OuterCircle-self.InnerCircle)*math.cos(theta) + self.Offset*(math.cos(((self.OuterCircle-self.InnerCircle)/self.InnerCircle)*theta))
		ptY[i] = (self.OuterCircle-self.InnerCircle)*math.sin(theta) - self.Offset*(math.sin(((self.OuterCircle-self.InnerCircle)/self.InnerCircle)*theta))
		theta = theta + step
	end

	local xLow = math.min(table.unpack(ptX))
	local xHi = math.max(table.unpack(ptX))
	local yLow = math.min(table.unpack(ptY))
	local yHi = math.max(table.unpack(ptY))

	local Pt
	local mesh =  moho:DrawingMesh()

	local xScale = (self.endVec.x-self.startVec.x)/ (xHi - xLow)
	local xOffset = (self.endVec.x+self.startVec.x)/2
	local yScale = (self.endVec.y-self.startVec.y) / (yLow - yHi) 
	local yOffset = self.endVec.y - (yScale * yLow) 




	for i = 0, self.drawnPoints - 1 do
		local pt = mesh:Point(self.startPoint + i)
		pt.fPos.x = xOffset + (xScale * ptX[i])
		pt.fPos.y = yOffset + (yScale * ptY[i])
	end

	mesh:SelectConnected()
	moho:AddPointKeyframe(drawingFrame)
	moho:UpdateSelectedChannels()


end




--*****************************
-- 	mouse actions
--*****************************


function HS_Hypotrochoid:OnMouseDown(moho, mouseEvent)

	local mesh =  moho:DrawingMesh()
	if (mesh == nil) then
		return
	end

	self.activeLayer = moho.drawingLayer


	moho.document:PrepUndo(moho.drawingLayer)
	moho.document:SetDirty()

--
--	to get a point at the "rose" intersections - the number of points in each cusp depends on the number of cusps
--	{9,14,12,12,10,12,14,16,18,10,22,12,26,14,30} points for 
-- 	{3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,16,17} cusps
--	this might be useful to try to get a filled shape without holes, but it's a real struggle to create such a shape... so for now it's fixed
	local ptsPerCusp = 12

	self.drawnPoints = math.min(360, ptsPerCusp*HS_Hypotrochoid.Cusps)

	makeLoop (moho, self.drawnPoints)

	mouseEvent.view:DrawMe()


end

function HS_Hypotrochoid:OnMouseMoved(moho, mouseEvent)
	local mesh =  moho:DrawingMesh()
	if (mesh == nil) then
		return
	end

	self.startVec:Set(mouseEvent.drawingStartVec)
	self.endVec:Set(mouseEvent.drawingVec)

	if (moho.gridOn) then
		moho:SnapToGrid(self.startVec)
		moho:SnapToGrid(self.endVec)
	end

	if mouseEvent.shiftKey then
		-- constrain width = height
		if (self.endVec.x > self.startVec.x) then
			if (self.endVec.y > self.startVec.y) then
				self.endVec.y = self.startVec.y + (self.endVec.x - self.startVec.x)
			else
				self.endVec.y = self.startVec.y - (self.endVec.x - self.startVec.x)
			end
		else
			if (self.endVec.y > self.startVec.y) then
				self.endVec.y = self.startVec.y - (self.endVec.x - self.startVec.x)
			else
				self.endVec.y = self.startVec.y + (self.endVec.x - self.startVec.x)
			end
		end
	end

	if mouseEvent.altKey then
		-- draw from center point
		self.startVec.x = self.startVec.x - (self.endVec.x - self.startVec.x)
		self.startVec.y = self.startVec.y - (self.endVec.y - self.startVec.y)
	end


	self:Draw(moho)

	mouseEvent.view:DrawMe()
end


function HS_Hypotrochoid:OnMouseUp(moho, mouseEvent)
	moho:UpdateUI()

--	self.startVec:Set(mouseEvent.drawingStartVec)
--	self.endVec:Set(mouseEvent.drawingVec)

end


local function OnKeyDownNew (moho, keyEvent)
	local mesh =  moho:DrawingMesh()
	if (mesh == nil) then
		return false
	end
	HS_Hypotrochoid.activeLayer = nil -- inactive until mouse click for draw
	mesh:SelectNone()
	return true
end


local function OnKeyDownDelete (moho, keyEvent)
	local mesh =  moho:DrawingMesh()
	if (mesh == nil) then
		return false
	end

	local deleted = false
	if moho:CountSelectedPoints() > 0 then 
		moho.document:PrepUndo(moho.drawingLayer)
		moho.document:SetDirty()
		mesh:DeleteSelectedPoints()
		deleted = true
	end

	return deleted
end


function HS_Hypotrochoid:OnKeyDown(moho, keyEvent)
	local keyDispatch = {
		{LM.GUI.KEY_DELETE, OnKeyDownDelete},
		{LM.GUI.KEY_BACKSPACE, OnKeyDownDelete},
		{LM.GUI.KEY_ESCAPE, OnKeyDownNew},
		{27, OnKeyDownNew}, 			-- the escape key
		{LM.GUI.KEY_BIND, OnKeyDownNew}		

	}

	local updateView = false

--[[
	local s = HS_Test_harness:diagnoseModifierKeys (keyEvent.ctrlKey, keyEvent.shiftKey, keyEvent.altKey)
	HS_Test_harness:diagnosePrint (thisUUT, "key code", keyEvent.keyCode, "mod keys", s)

]]
	if keyEvent.ctrlKey then
--		HS_Test_harness:diagnosePrint (thisUUT, "using factory point tool")
		LM_SelectPoints:OnKeyDown(moho, keyEvent)
		return
	end

	local i
	for i = 1, #keyDispatch do
		if keyEvent.keyCode == keyDispatch[i][1] then
			updateView = keyDispatch[i][2] (moho, keyEvent)
			break
		end
	end

	if updateView then
--		HS_Test_harness:diagnosePrint (thisUUT, "updating view")
		moho:UpdateSelectedChannels()
		keyEvent.view:DrawMe()
	end
end








-- **************************************************
-- **************************************************
-- **************************************************
-- *************************************************************************************************************************************** UI
-- **************************************************




-- **************************************************
-- Tool options - data values
-- **************************************************

local presetBase = MOHO.MSG_BASE + 20
HS_Hypotrochoid.PRESET = presetBase + 1
HS_Hypotrochoid.ALT_PRESET = presetBase - 1


HS_Hypotrochoid.OUTER = MOHO.MSG_BASE + 110
HS_Hypotrochoid.INNER = MOHO.MSG_BASE + 120
HS_Hypotrochoid.OFFSET = MOHO.MSG_BASE + 130
HS_Hypotrochoid.CUSPS = MOHO.MSG_BASE + 140


--[[
local function doHypoC ()

	HS_Hypotrochoid.OuterCircleIn:Enable(false)
	HS_Hypotrochoid.OffsetIn:Enable(false)
	HS_Hypotrochoid.OuterCircle = 2 * HS_Hypotrochoid.Cusps
--	HS_Hypotrochoid.InnerCircle (as calc)
	HS_Hypotrochoid.Offset = HS_Hypotrochoid.OuterCircle / HS_Hypotrochoid.Cusps
--	HS_Hypotrochoid.Cusps (as entered)

return

local function doEllipse ()

	HS_Hypotrochoid.OuterCircleIn:Enable(false)
	HS_Hypotrochoid.OuterCircle = 2 * HS_Hypotrochoid.Cusps
--	HS_Hypotrochoid.InnerCircle (as calc)
--	HS_Hypotrochoid.Offset (as entered)
--	HS_Hypotrochoid.Cusps (as entered)

return

local function doRose ()

	HS_Hypotrochoid.OffsetIn:Enable(false)
--	HS_Hypotrochoid.OuterCircle (as entered)
--	HS_Hypotrochoid.InnerCircle (as calc)
	HS_Hypotrochoid.Offset = HS_Hypotrochoid.OuterCircle - HS_Hypotrochoid.InnerCircle
--	HS_Hypotrochoid.Cusps (as entered)

return


local function doEntry ()

	HS_Hypotrochoid.OuterCircleIn:Enable(true)
	HS_Hypotrochoid.OffsetIn:Enable(true)
	HS_Hypotrochoid.CuspsIn:Enable(true)

return
]]

local presets = {

	{name="hypocycloid", outer=6, cusps=3, offset=2, fnc=doHypoC},
	{name="ellipse", outer=4, cusps=2, offset=.5, fnc=doEllipse},
	{name="rose (12)", outer=4, cusps=12, offset=(4 - 1/3), fnc=doRose},

	{name="data entry", outer=nil, cusps=nil, offset=nil, fnc=doEntry} -- end marker

}

local presetIx = #presets-1 -- counts from 0

local entered_Outer = HS_Hypotrochoid.OuterCircle
local entered_Inner = HS_Hypotrochoid.InnerCircle
local entered_Offset = HS_Hypotrochoid.Offset
local entered_Cusps = HS_Hypotrochoid.Cusps

-- **************************************************
-- Create and respond to tool's UI
-- **************************************************


function HS_Hypotrochoid:DoLayout(moho, layout)

	local i

	self.butt = LM.GUI.Button(presets[presetIx+1].name, self.PRESET)
	self.butt:SetAlternateMessage(self.ALT_PRESET)
	layout:AddChild(self.butt)


--	self.OuterCircleText = LM.GUI.StaticText("Outer Circle")
--	layout:AddChild(self.OuterCircleText, LM.GUI.ALIGN_RIGHT)
	self.OuterCircleIn = LM.GUI.TextControl (0, "0.0000", self.OUTER, LM.GUI.FIELD_UFLOAT, "Outer Circle")
	layout:AddChild(self.OuterCircleIn, LM.GUI.ALIGN_RIGHT)

--	self.CuspsText = LM.GUI.StaticText("Cusps (loops)")
--	layout:AddChild(self.CuspsText, LM.GUI.ALIGN_RIGHT)
	self.CuspsIn = LM.GUI.TextControl (0, "00", self.CUSPS, LM.GUI.FIELD_UINT, "Cusps (loops)")
	layout:AddChild(self.CuspsIn, LM.GUI.ALIGN_RIGHT)
	self.CuspsIn:SetWheelInc(1)


--[[
	self.InnerCircleText = LM.GUI.StaticText("Inner Circle")
	layout:AddChild(self.InnerCircleText, LM.GUI.ALIGN_RIGHT)
	self.InnerCircleIn = LM.GUI.TextControl (0, "0.0000", self.INNER, LM.GUI.FIELD_FLOAT)
	layout:AddChild(self.InnerCircleIn, LM.GUI.ALIGN_RIGHT)
]]

--	self.OffsetText = LM.GUI.StaticText("Offset")
--	layout:AddChild(self.OffsetText, LM.GUI.ALIGN_RIGHT)
	self.OffsetIn = LM.GUI.TextControl (0, "0.0000", self.OFFSET, LM.GUI.FIELD_FLOAT, "Offset")
	layout:AddChild(self.OffsetIn, LM.GUI.ALIGN_RIGHT)



end


function HS_Hypotrochoid:UpdateWidgets(moho)
	self.OuterCircleIn:SetValue(self.OuterCircle)
--	self.InnerCircleIn:SetValue(self.InnerCircle)
	self.OffsetIn:SetValue(self.Offset)
	self.CuspsIn:SetValue(self.Cusps)

	local function setWheel (x)
		x = math.abs(x)
		local i = math.floor(math.log (x, 10))
		if i < -1 then
			i = -2
		else
			i = i - 1
		end

--		HS_Test_harness:diagnosePrint (thisUUT, "set Wheel", x, math.log (x, 10), i)

		return 10^i

	end


--	HS_Test_harness:diagnosePrint (thisUUT, "for set Wheel", self.OuterCircle, self.Offset)

	
	self.OuterCircleIn:SetWheelInc(setWheel(self.OuterCircle))
--	self.InnerCircleIn:SetWheelInc(self.wheel)
	self.OffsetIn:SetWheelInc(setWheel(self.Offset))
--	self.CuspsIn:SetWheelInc(1)

	self.butt:SetLabel(presets[presetIx+1].name, false)
	self.butt:Redraw()
--	HS_Test_harness:diagnosePrint (thisUUT, "update widgets", wheelIx, wheelInc[wheelIx])

	HS_Hypotrochoid.OuterCircleIn:Redraw()
--	HS_Hypotrochoid.InnerCircleIn:Redraw()
	HS_Hypotrochoid.OffsetIn:Redraw()
	HS_Hypotrochoid.CuspsIn:Redraw()

end



function HS_Hypotrochoid:HandleMessage(moho, view, msg)

	if (msg == self.OUTER) then
		self.OuterCircle = self.OuterCircleIn:FloatValue()

--	elseif 	(msg == self.INNER) then
---		self.InnerCircle = self.InnerCircleIn:FloatValue()

	elseif (msg == self.OFFSET) then
		self.Offset = self.OffsetIn:FloatValue()

	elseif (msg == self.CUSPS) then
		self.Cusps = self.CuspsIn:IntValue()
		if self.Cusps < 2 then
			self.Cusps = 2
		end

	elseif (msg == self.PRESET) or (msg == self.ALT_PRESET) then
		presetIx = math.fmod(presetIx + #presets + (msg-presetBase), #presets)

		if presets[presetIx+1].outer then -- if it's data entry then restore the entered values and put in the presets
			entered_Outer = self.OuterCircle
			entered_Inner = self.InnerCircle
			entered_Offset = self.Offset
			entered_Cusps = self.Cusps

			self.OuterCircle = presets[presetIx+1].outer
--			self.InnerCircle (calculated in mainline)
			self.Offset = presets[presetIx+1].offset
			self.Cusps = presets[presetIx+1].cusps
		else
			self.OuterCircle = entered_Outer
			self.InnerCircle = entered_Inner
			self.Offset = entered_Offset
			self.Cusps = entered_Cusps
		end

	end

	self:UpdateWidgets(moho)
	HS_Hypotrochoid:Draw(moho)

end

Icon
Spirograph
Listed

Script type: Tool

Uploaded: Oct 23 2022, 14:54

Last modified: May 12 2023, 04:56

Draws a path similarly to drawing with a spirograph.
A hypotrochoid is generated by a fixed point on a circle rolling inside a fixed circle. This script allows the user to select the radius of the outer (fixed) circle, the number of nodes (i.e. how many times the inner circle will roll round the outer circle) and how far off centre the "pen" is.
The shape is created on frame 0. It can be modified after it is created either on frame 0 or in the timeline by changing the input parameters. 

This is a preliminary version. The pace of updates will depend on feedback on its usefulness! 

Here's a video that uses the script to generate many of the shapes.


2023-05-12 Vers 1.03a - Minor update to remove an unnecessary global variable. No functional changes. 
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: 571