J/Link Example: Spirograph

Code for the Spirograph application from section 1.3 of the J/Link User Guide.

You need a 1.2 or later version of Java for this example, because it uses features of the Java Graphics2D API. This means that it cannot run on the Macintosh (at least until Mathematica and J/Link are ported to Mac OS X), and it cannot run on Windows using the Microsoft Java runtime.

This is a reasonably complex application coded entirely in Mathematica. It demonstrates drawing into a MathCanvas using the Java Graphics2D API, along with a number of MathListeners to script the functionality of the user interface.

To try this example, evaluate all the initialization cells (you can do this with the menu command Kernel/Evaluation/Evaluate Initialization). Then go to the Example section.

The curve drawn by a wheel rolling along the outside of a circle is called an epitrochoid, and the curve drawn when the wheel rolls along the inside of a circle is called a hypotrochoid. For more information, see http://mathworld.wolfram.com/Hypotrochoid.html and http://mathworld.wolfram.com/Epitrochoid.html.

It is left as an exercise for the reader to improve the user interface.

Code

(* We put the code into its own package just to avoid conflicts over definitions of
   commonly-named (for me, at least) functions like "mousePressedFunc" and "mouseMovedFunc".
*)
BeginPackage["Spirograph`", "JLink`"];

Spirograph;

Begin["`Private`"];

(* Equations for the x and y coords of epitrochoids and hypotrochoids. The angle theta
   is the starting angle of the center of the wheel, and phi is the starting angle of
   the pen point, measured from the horizontal line that goes through the center of the wheel. *)

HypoX[a_, b_, h_, theta_, phi_] := (a - b) Cos[t + theta] + h Cos[(a - b)t/b - phi]
HypoY[a_, b_, h_, theta_, phi_] := (a - b) Sin[t + theta] - h Sin[(a - b)t/b - phi]

EpiX[a_, b_, h_, theta_, phi_] := (a + b) Cos[t + theta] + h Cos[(a + b)t/b + phi]
EpiY[a_, b_, h_, theta_, phi_] := (a + b) Sin[t + theta] + h Sin[(a + b)t/b + phi]

thisPkgContext = Context[];

Spirograph[] :=
	JavaBlock[
		Block[{frame, mathCanvas, buttonPanel, mml, mmml, wheelButtonListener,
				defaultCursor, moveCursor, resizeCursor, crosshairCursor,
				penColor, defaultPenColor, ringColor, wheelColor, blackColor, fatStroke, thinStroke,
				ringButton, wheelInnerButton, wheelOuterButton, penColorButton, penButton,
				viewButton, clearButton, buttonGroup, wheelRadius, ringRadius,
				offscreen, g, imWidth, imHeight, centerX, centerY, wheelCenterX, wheelCenterY,
				origWheelCenter, isEpi, mode, phi, h, oldCurves, penPt, xPts, yPts, numPts, ptsUsed,
				startTheta, lastTheta, thetaCorrection, period, periodProportion,
				wheelRotations, rollovers
			  },
		
			InstallJava[];
			
			If[LoadJavaClass["java.awt.Graphics2D"] === $Failed,
				Print["You must have Java 1.2 or later for this example."];
				Return[$Failed]
			];
			LoadJavaClass["java.awt.Cursor"];
			defaultCursor = Cursor`getPredefinedCursor[Cursor`DEFAULTUCURSOR];
			moveCursor = Cursor`getPredefinedCursor[Cursor`MOVEUCURSOR];
			resizeCursor = Cursor`getPredefinedCursor[Cursor`NWURESIZEUCURSOR];
			crosshairCursor = Cursor`getPredefinedCursor[Cursor`CROSSHAIRUCURSOR];
			
			LoadJavaClass["java.awt.Color"];
			penColor = defaultPenColor = Color`green;
			blackColor = Color`black;
			ringColor = JavaNew["java.awt.Color", 1.0, 0., 0., 0.3];
			wheelColor = JavaNew["java.awt.Color", 0., 0., 1.0, 0.3];
			
			fatStroke = JavaNew["java.awt.BasicStroke", 4];
			thinStroke = JavaNew["java.awt.BasicStroke", 1];
			
			frame = JavaNew["com.wolfram.jlink.MathFrame", "Spirograph"];
			frame@setResizable[False];
			frame@setLayout[JavaNew["java.awt.BorderLayout"]];
			mathCanvas = JavaNew["com.wolfram.jlink.MathCanvas"];
			frame@add["Center", mathCanvas];
			buttonPanel = JavaNew["java.awt.Panel"];
			frame@add["West", buttonPanel];
			buttonPanel@setLayout[JavaNew["java.awt.GridLayout", 7, 1]];
			ringButton = JavaNew["javax.swing.JToggleButton", "Ring"];
			wheelInnerButton = JavaNew["javax.swing.JToggleButton", "Inner Wheel"];
			wheelOuterButton = JavaNew["javax.swing.JToggleButton", "Outer Wheel"];
			penButton = JavaNew["javax.swing.JToggleButton", "Pen"];
			penColorButton = JavaNew["javax.swing.JToggleButton", "Pen Color..."];
			viewButton = JavaNew["javax.swing.JToggleButton", "View"];
			clearButton = JavaNew["javax.swing.JToggleButton", "Clear"];
			buttonPanel@add[ringButton];
			buttonPanel@add[wheelInnerButton];
			buttonPanel@add[wheelOuterButton];
			buttonPanel@add[penButton];
			buttonPanel@add[penColorButton];
			buttonPanel@add[viewButton];
			buttonPanel@add[clearButton];
			
			(* Note the trick of prepending Context[] to the string names of the callback functions.
			   These functions are in the Spirograph`Private` context, so we must make sure that when
			   they are called their full names are used.
			*)
			ringButton@addActionListener[
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "ringButtonPushed"]
			];
			penButton@addActionListener[
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "penButtonPushed"]
			];
			penColorButton@addActionListener[
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "penColorButtonPushed"]
			];
			viewButton@addActionListener[
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "viewButtonPushed"]
			];
			clearButton@addActionListener[
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "clearButtonPushed"]
			];
			wheelButtonListener =
				JavaNew["com.wolfram.jlink.MathActionListener", thisPkgContext <> "wheelButtonPushed"];
			wheelInnerButton@addActionListener[wheelButtonListener];
			wheelOuterButton@addActionListener[wheelButtonListener];

			buttonGroup = JavaNew["javax.swing.ButtonGroup"];
			buttonGroup@add[ringButton];
			buttonGroup@add[wheelInnerButton];
			buttonGroup@add[wheelOuterButton];
			buttonGroup@add[penButton];
			buttonGroup@add[penColorButton];
			buttonGroup@add[viewButton];
			buttonGroup@add[clearButton];

			mmml = JavaNew["com.wolfram.jlink.MathMouseMotionListener"];
			mmml@setHandler["mouseDragged", thisPkgContext <> "mouseDraggedFunc"];
			mmml@setHandler["mouseMoved", thisPkgContext <> "mouseMovedFunc"];
			mathCanvas@addMouseMotionListener[mmml];
			mml = JavaNew["com.wolfram.jlink.MathMouseListener"];
			mml@setHandler["mousePressed", thisPkgContext <> "mousePressedFunc"];
			mml@setHandler["mouseReleased", thisPkgContext <> "mouseReleasedFunc"];
			mathCanvas@addMouseListener[mml];

			frame@setSize[550, 400];
			frame@layout[];
			JavaShow[frame];
			
			offscreen = mathCanvas@createImage[
				mathCanvas@getSize[]@width, mathCanvas@getSize[]@height];
			g = offscreen@createGraphics[];
			imWidth = offscreen@getWidth[];
			imHeight = offscreen@getHeight[];
			{centerX, centerY} = Round[{imWidth/2, imHeight/2}];
			
			oldCurves = {};
		
			(* Initialize into ring-resize mode. *)
			ringButton@doClick[];
			
			frame@setModal[];
			DoModal[]
		]
	]

ringButtonPushed[e_, _] :=
	(
		mode = "RING";
		If[ValueQ[penPt], AppendTo[oldCurves, {xPts, yPts, ptsUsed, penColor}]];
		ringRadius = 80;  (* Default ring radius. *)
		drawPicture[];
		ReleaseJavaObject[e]
	)
	
wheelButtonPushed[e_, cmd_] :=
	(
		mode = "WHEEL";
		isEpi = cmd === "Outer Wheel";
		If[ValueQ[penPt], AppendTo[oldCurves, {xPts, yPts, ptsUsed, penColor}]];
		wheelRadius = 30;  (* Default wheel radius. *)
		{wheelCenterX, wheelCenterY} =
			Round[{centerX, centerY - (ringRadius + If[isEpi, 1, -1] wheelRadius)}];
		drawPicture[];
		ReleaseJavaObject[e]
	)
	
penButtonPushed[e_, _] :=
	(
		mode = "PEN";
		ReleaseJavaObject[e]
	)

penColorButtonPushed[e_, _] :=
	Module[{color},
		If[ValueQ[penPt], AppendTo[oldCurves, {xPts, yPts, ptsUsed, penColor}]];
		penPt =.;
		LoadJavaClass["javax.swing.JColorChooser"];
		color = JColorChooser`showDialog[mathCanvas, "Select Pen Color:", defaultPenColor];
		If[color =!= Null, penColor = color];
		ReleaseJavaObject[e]
	]

viewButtonPushed[e_, _] :=
	(
		mode = "VIEW";
		drawPicture[];
		ReleaseJavaObject[e]
	)

clearButtonPushed[e_, _] :=
	(
		mode = "CLEAR";
		penPt =.;
		penColor = defaultPenColor;
		oldCurves = {};
		drawPicture[];
		ReleaseJavaObject[e]
	)

mousePressedFunc[e_, x_, y_, _] :=
	Module[{cfx, cfy, mouseAngle, theta},
		(* theta is the angle from the horizontal of the center of the wheel. *)
		theta = computeAngle[{centerX, centerY}, {wheelCenterX, wheelCenterY}];
		mouseAngle = computeAngle[{centerX, centerY}, {x, y}];
		thetaCorrection = theta - mouseAngle;
		
		Switch[mode,
			"RING",
				If[SameObjectQ[mathCanvas@getCursor[], resizeCursor],
					mode = "RINGRESIZE"
				],
			"WHEEL",
				If[SameObjectQ[mathCanvas@getCursor[], resizeCursor],
					origWheelCenter = {wheelCenterX, wheelCenterY};
					mode = "WHEELRESIZE"
				],
			"PEN",
				If[ValueQ[penPt], AppendTo[oldCurves, {xPts, yPts, ptsUsed, penColor}]];
				penPt = {x, y};
				h = Sqrt[(wheelCenterX - x)^2 + (wheelCenterY - y)^2];
				phi = computeAngle[{wheelCenterX, wheelCenterY}, {x, y}];
				If[isEpi,
					cfx = Compile[{t}, Evaluate[centerX + EpiX[ringRadius, wheelRadius, h, theta, phi]]];
					cfy = Compile[{t}, Evaluate[centerY - EpiY[ringRadius, wheelRadius, h, theta, phi]]];
					period = 2 Pi Numerator[wheelRadius/(ringRadius + wheelRadius)];
					(* This is the number of rotations the wheel will make over the
					   full period of the curve.
					*)
					wheelRotations = Denominator[wheelRadius/(ringRadius + wheelRadius)],
				(* else *)
					cfx = Compile[{t}, Evaluate[centerX + HypoX[ringRadius, wheelRadius, h, theta, phi]]];
					cfy = Compile[{t}, Evaluate[centerY - HypoY[ringRadius, wheelRadius, h, theta, phi]]];
					period = 2 Pi Numerator[wheelRadius/(ringRadius - wheelRadius)];
					wheelRotations = Denominator[wheelRadius/(ringRadius - wheelRadius)]
				];
				(* 200 pts per 2Pi. *)
				xPts = MakeJavaObject[Round @ Table[cfx[t], {t, 0., period, 2Pi/200.}]];
				yPts = MakeJavaObject[Round @ Table[cfy[t], {t, 0., period, 2Pi/200.}]];
				numPts = period/(2Pi/200.) + 1;
				periodProportion = rollovers = 0;
				startTheta = lastTheta = theta
		];
		drawPicture[];
		ReleaseJavaObject[e]
	]

mouseDraggedFunc[e_, x_, y_, _] :=
	Module[{mouseAngle, penAngle, theta},
		Switch[mode,
			"RINGRESIZE",
				ringRadius = Max[10, Round[Sqrt[(centerX - x)^2 + (centerY - y)^2]]],
			"WHEELRESIZE",
				wheelRadius = Max[6, Min[If[isEpi, Infinity, ringRadius - 4],
									Round @ Sqrt[Plus @@ ((origWheelCenter - {x, y})^2)]]];
				theta = computeAngle[{centerX, centerY}, {wheelCenterX, wheelCenterY}];
				{wheelCenterX, wheelCenterY} = Round[{centerX, centerY} +
							(ringRadius + If[isEpi, 1, -1] wheelRadius) {Cos[theta], -Sin[theta]}],
			"WHEEL" | "PEN",
				mouseAngle = computeAngle[{centerX, centerY}, {x, y}];
				theta = mouseAngle + thetaCorrection;
				{wheelCenterX, wheelCenterY} = Round[{centerX, centerY} + 
							(ringRadius + If[isEpi, 1, -1] wheelRadius) {Cos[theta], -Sin[theta]}];
				If[mode === "PEN",
					If[Abs[theta - lastTheta] > Pi, rollovers += If[x >= centerX, 1, -1]];
					periodProportion = (theta + 2Pi rollovers - startTheta)/period;
					lastTheta = theta;
					penAngle = phi + If[isEpi, 1, -1] wheelRotations periodProportion 2Pi;
					penPt = Round[{wheelCenterX + h Cos[penAngle], wheelCenterY - h Sin[penAngle]}]
				]
		];
		drawPicture[];
		ReleaseJavaObject[e]
	]

(* This is used for changing the cursor as the mouse floats over areas of the figure. *)
mouseMovedFunc[e_, x_, y_, _] :=
	Module[{distToWheelCenter, distToCenter},
		Switch[mode,
			"PEN",
				mathCanvas@setCursor[crosshairCursor],
			"WHEEL",
				distToWheelCenter = Sqrt[(wheelCenterX - x)^2 + (wheelCenterY - y)^2];
				Which[
					distToWheelCenter < wheelRadius + 2 && distToWheelCenter > wheelRadius - 6,
						mathCanvas@setCursor[resizeCursor],
					distToWheelCenter <= wheelRadius - 6,
						mathCanvas@setCursor[moveCursor],
					True,
						mathCanvas@setCursor[defaultCursor]
				],
			"RING",
				distToCenter = Sqrt[(centerX - x)^2 + (centerY - y)^2];
				If[Abs[distToCenter - ringRadius] <= 2,
					mathCanvas@setCursor[resizeCursor],
				(* else *)
					mathCanvas@setCursor[defaultCursor]
				]
		];
		ReleaseJavaObject[e]
	]

mouseReleasedFunc[e_, _, _, _] :=
	(
		Switch[mode,
			"RINGRESIZE",
				mode = "RING",
			"WHEELRESIZE",
				mode = "WHEEL"
		];
		ReleaseJavaObject[e]
	)

drawPicture[] :=
	(
		g@clearRect[0, 0, imWidth, imHeight];
		If[mode =!= "CLEAR",
			g@setStroke[thinStroke];
			(* Draw the pre-existing curves. *)
			(g@setColor[#4]; g@drawPolyline[#1, #2, #3])& @@@ oldCurves;
			If[ValueQ[penPt],
				ptsUsed = Round[Max[0, Min[numPts, numPts periodProportion]]];
				g@setColor[penColor];
				g@drawPolyline[xPts, yPts, ptsUsed];
			];
			If[mode =!= "VIEW",
				(* Draw the ring. *)
				g@setPaint[ringColor];
				g@setStroke[fatStroke];
				g@drawOval[centerX - ringRadius, centerY - ringRadius, 2 ringRadius, 2 ringRadius];
			];
			If[mode === "WHEEL" || mode === "WHEELRESIZE" || mode === "PEN",
				(* Draw the wheel. *)
				g@setPaint[wheelColor];
				g@fillOval[wheelCenterX - wheelRadius, wheelCenterY - wheelRadius, 2 wheelRadius, 2 wheelRadius];
				If[mode === "PEN",
					g@setColor[blackColor];
					g@setStroke[thinStroke];
					g@fillOval[penPt[[1]] - 2, penPt[[2]] - 2, 5, 5];
					g@drawLine[wheelCenterX, wheelCenterY, penPt[[1]], penPt[[2]]];
				]
			]
		];
		mathCanvas@setImage[offscreen];
		mathCanvas@repaintNow[];
	)

computeAngle[{x1_, y1_}, {x2_, y2_}] :=
	If[x2 === x1,
		Pi/2. Sign[y1 - y2],
	(* else *)
		ArcTan[(y1 - y2)/(x2 - x1)] +
			If[x2 < x1, N[Pi], 0.]
	]
	
End[];
EndPackage[];

Example

The spirograph application presents a column of buttons. Click the Ring button to place a ring that the wheels will roll around. When the Ring button is down, you can use the mouse to resize the ring. Click the InnerWheel or Outer Wheel buttons to place a wheel. When either Wheel button is down, you can use the mouse to move or resize the wheel. Click the Pen button to start drawing. To draw, click the mouse inside or outside the wheel, then move the mouse counter-clockwise while holding down the button. Moving clockwise erases. Use the Pen Color button to adjust the color. The View button removes the ring and wheel elements so you can see your picture unhindered, and the Clear button resets the slate. You can add as many curves as you like, with different ring diameters, wheel sizes, pen colors, etc.

Spirograph[]