# Paintable Canvas Hacks

**URL:** <https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437>\
**Category:** Ignition\
**Tags:** vision\
**Created:** [February 8, 2024, 3:25pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437 "2024-02-08T15:25:06Z")\
**Posts on this page:** 20\
**Page:** 1

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [February 8, 2024, 3:25pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/1 "2024-02-08T15:25:06Z")

</div>

I've been playing around with the paintable canvas quite a bit here lately for fun, and I was wondering if anybody had any cool hacks for canvas development they would be willing to share.

**To start things off, I'll share a simple way to get drawn curves right in the canvas.**

**Edit:** _The gauge example below is just a component with a lot of curves. There are better and easier ways to make such a gauge with the canvas, but for the purposes of this post, the number of curves this component has makes it ideal for demonstrating this method._

**A simple method for drawing perfect curves with the paintable canvas:**  
Using variable names that are self explanitory, the drawArc method looks like this:

```auto
g.drawArc(x, y, width, height, startingAngle, angleLengthInDegrees)

```

I've found that if you take the circle tool, give it a transparent background, and position it where you want the curve to be, its x, y, width and height parameters will be exactly right.

Therefore. if I wanted to make something like this custom gauge with rounded edges:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/1/b/1be11ee99de4e68aa456ecf36d775f37da712afd.png)

I could start by simply overlaying my canvas with simple shapes produced by the round shape tool to get the proper dimensions:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/7/1/7130a6fda26ba7f3001fed67d6ae5b79b1503e69.png)

![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/8/1/8104ec85b30c1d3a46fb57fb4ba2d8d89a2327cf.png)

Then, select each shape and press ctrl-P to get the parameters:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/e/7/e789e93259aa936716b6f55262bbb58fb9c9debf.png)  
In my case, the bounds for the four circles are:

```auto
[46, 221, 50, 50] # Left small circle
[204, 221, 50, 50] # Right small circle
[50, 50, 200, 200] # Large inner circle
[0, 0, 300, 300] # Large outer circle

```

Once this is done there will only be two parameters needed for each of the four arcs. This can be determined by simply using a unit circle:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/f/5fbf90e25baff8e60e68715f5d10a04c6c4aa698.png)

It looks like my two large circles should _start at about **315 degrees** _ and end at around 225 degrees. That would be _a total of **270 degrees** travel_, so my code would look like this:

```auto
g = event.graphics

g.drawArc(0, 0, 300, 300, 315, 270)
g.drawArc(50, 50, 200, 200, 315, 270)

```

Looking again at the unit circle, I can estimate that my small left circle should _start at around **225 degrees** _ and end _ **180 degrees** later_ at 45 degrees. My right small circle looks like it should _start at around **135 degrees** _, and end _ **180 degrees** later_ at 315 degrees, so I'll add these lines to my code:

```auto
g.drawArc(46, 221, 50, 50, 225, 180)
g.drawArc(204, 221, 50, 50, 135, 180)

```

When I run preview mode, I see this in my canvas:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/4/b/4b90472ac7dc0abde3aa2a8370416e2d3d6c302b.png)

It's not perfect, but it's pretty darn close. From here, a little trial and error to tweak the numbers produces this result:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/3/536edfdaef31336d109ed22c7787141c1fa3431a.png)

```auto
g = event.graphics

# Large Outer Circle
g.drawArc(0, 0, 300, 300, 310, 280)

# Large Inner Circle
g.drawArc(50, 50, 200, 199, 309, 283)

# Small Left Arc
g.drawArc(46, 221, 51, 51, 228, 180)

# Small Right Arc
g.drawArc(204, 221, 50, 50, 130, 180)

```

---

<div class="post-metadata">

**Author:** ![zxcslo](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/zxcslo/32/61610_2.png) [@zxcslo](https://forum.inductiveautomation.com/u/zxcslo)\
**Post date:** [February 9, 2024, 6:25am UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/2 "2024-02-09T06:25:43Z")

</div>

This is how I did it a few years ago...  
 ![GaugeArcDonutAnimation](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/2/5242cdc24a50f0c4bff7b869b9e7f8d83d11b14d.gif)  
[GaugeCanvasArcDonutAnimated.zip](https://forum.inductiveautomation.com/uploads/short-url/5BWSjR5kumjzcRRbbcwyGpGFSSz.zip) (23.5 KB)  
I'm using `Arc2D.Double` with `g.setStroke(BasicStroke(arcwidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER))`.  
CAP\_ROUND is for rounded ends, CAP\_BUTT is for normal ends...

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [April 8, 2024, 4:11pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/5 "2024-04-08T16:11:55Z")

</div>

**Customized Borders:**  
Simply fill the bottom layer of a container with a paintable canvas to create custom borders like these:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/8/b/8b3186109ba08585f0dc510af986c150a65ea897.png)

```auto
# Curved border
# Developed for the repaint event handler of a paintable canvas component

# Define the parent container
parent = event.source.parent

# Define a border width
borderWidth = 15

# Gradient severity
darkenPerPixel = 3

# Get graphics instance
graphics = event.graphics

# Define the unedited background color using rgba intengers
# Since the border color shifts by the amount to darken for every pixel of border width,
# ...each rgb must be at least the same integer value as the border width to ensure no integer can be calculated as less than zero.
startingRed = parent.background.red if parent.background.red >= borderWidth * darkenPerPixel else borderWidth * darkenPerPixel
startingGreen = parent.background.green if parent.background.green >= borderWidth else borderWidth * darkenPerPixel
startingBlue = parent.background.blue if parent.background.blue >= borderWidth else borderWidth * darkenPerPixel

# If any rgb values were adjusted, apply the adjusted values to the parent container's background property
startingBackground = system.gui.color(startingRed, startingGreen, startingBlue, parent.background.alpha)
if parent.background != startingBackground:
	parent.background = startingBackground

# For each side of the canvas, paint the border
for side in range(4):

	# Initialize RGB values for the border. For left and top (side 0 and 1), reduce each by borderWidth
	# For bottom and right (side 2 and 3), keep the original starting RGB values
	# Determine the start and end points for drawing lines based on the side being processed
	if side == 0 or side == 1: # Left side or Top side
		red = startingRed - borderWidth * darkenPerPixel
		green = startingGreen - borderWidth * darkenPerPixel
		blue = startingBlue - borderWidth * darkenPerPixel
		start = 0
		end = borderWidth
	else:
		red = startingRed
		green = startingGreen
		blue = startingBlue				
		if side == 3: # Right side
			start = event.source.width - borderWidth
			end = event.source.width
		else: # Bottom side
			start = event.source.height - borderWidth
			end = event.source.height
			
	# Draw the border lines, adjusting the RGB values to create a gradient effect along each side
	for pixel in range(start, end):
	
		# Increment or decrement RGB values to create a rounded fading effect on the border
		if pixel - start < borderWidth and side < 2 :
			red += darkenPerPixel
			green += darkenPerPixel
			blue += darkenPerPixel
		else:
			red -= darkenPerPixel
			green -= darkenPerPixel
			blue -= darkenPerPixel
			
		# Set the current color for drawing
		graphics.setColor(system.gui.color(red, green, blue))
		
		# Draw lines based on the side being processed to create the border effect
		if side == 0: # Left side
			graphics.drawLine(pixel, (0 + pixel), pixel, (event.source.height - pixel))
		elif side == 1: # Top side
			graphics.drawLine((0 + pixel), pixel, (event.source.width - pixel), pixel)
		elif side == 3: # Right side
			increment = event.source.width - pixel
			graphics.drawLine(pixel, (0 + increment), pixel, (event.source.height - increment))
		else: # Bottom side
			increment = event.source.height - pixel
			graphics.drawLine((0 + increment), pixel, (event.source.width - increment), pixel)

```

---

<div class="post-metadata">

**Author:** ![lrose](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/lrose/32/62724_2.png) [@lrose](https://forum.inductiveautomation.com/u/lrose)\
**Post date:** [April 8, 2024, 6:08pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/6 "2024-04-08T18:08:03Z")

</div>

That looks familiar...

> [@Circular Slider in canvas](https://forum.inductiveautomation.com/t/circular-slider-in-canvas/26254):
>
> OK, after 4 days I have enough… disappointed_relieved It seems that (at least for me) this seems to be more difficult than I thought. Making a circular slider is way more different/difficult than making a circular gauge. I’m pretty sure that my lack of knowledge is what’s causing me problems (I also think that the overall approach to this is wrong), but that’s it. Here is what I came up with: [CircularSlider\_2019-07-03\_0941.proj](https://us1.discourse-cdn.com/inductiveautomation/original/2X/2/297b417e351289018c8991066504856ed9af7470.proj) (322.7 KB) Feel free to use, experiment with and in case some…

This demonstrates using hit testing on the canvas to turn your gauge into a "slider" for input purposes.

Be warned in advance that there is some "fancy" math involved.

I also often find that is useful to do all of the drawing at a known unit size and then transform and/or scale to the final size at the end.

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [April 8, 2024, 6:12pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/7 "2024-04-08T18:12:55Z")

</div>

> [@lrose](#):
>
> transform and/or scale to the final size at the end.

These lines from the default component example are handy:

```auto
#### Scale graphics to actual component size
dX = (event.width-1)/100.0
dY = (event.height-1)/100.0
g.scale(dX,dY)

```

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [April 30, 2024, 3:35pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/8 "2024-04-30T15:35:32Z")

</div>

**On Screen Presentation Mouse:**  
 ![Presentation3](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/a/5aea56fdb51cce3e866c974cbe8208730464f39a.gif)

I was doing a demo video for [a tutorial I made about modifying a slider's click behavior](https://forum.inductiveautomation.com/t/bug-slider-bar-click-behavior/89650/4), and to make the mouse clicks visible, I created a simple demo mouse with the paintable canvas. It's useful, so I'm posting the tutorial on how to create it here in case anybody else would like to use it.

**First:** add two custom boolean properties to the paintable canvas called `leftClick` and `rightClick`:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/c/f/cf2fe8517f09d15de95f471b8c5d64c924faf79a.png)

**Second:** Add this script to the paintable canvas's repaint event handler:

```python
# Get the graphics object from the event
graphics = event.graphics

# Create the main body of the mouse
graphics.setColor(system.gui.color('grey'))
graphics.fillOval(40, 20, 120, 200)

# Set the button colors according to whether or not they have been clicked
# and create the buttons

# Left Button
if event.source.leftClick:
	graphics.setColor(system.gui.color('lightgreen'))
	graphics.fillArc(50, 50, 100, 60, 90, 180)
	
else:
	graphics.setColor(system.gui.color('lightgrey'))
graphics.fillArc(50, 50, 100, 60, 90, 180)

# Right Button
if event.source.rightClick:
	graphics.setColor(system.gui.color('lightgreen'))
	graphics.fillArc(50, 50, 100, 60, 270, 180)

else:
	graphics.setColor(system.gui.color('lightgrey'))
graphics.fillArc(50, 50, 100, 60, 270, 180)

graphics.setColor(system.gui.color('black'))
#Draw a centerline between the buttons
graphics.drawLine(100, 50, 100, 110)

# Create a center mouse wheel
graphics.setColor(system.gui.color('grey'))
graphics.fillOval(95, 65, 10, 30)

# Create a shadow around the buttons when they are clicked,
# ...to create a depressed effect
if event.source.leftClick:
	graphics.setColor(system.gui.color('black'))
	graphics.drawArc(50, 50, 100, 60, 90, 180)
if event.source.rightClick:
	graphics.setColor(system.gui.color('black'))
	graphics.drawArc(50, 50, 100, 60, 270, 180)

```

**Finally:** On the component that is being clicked on for the demonstration, add this script to the mousePressed event handler:

```python
if event.button == 1: # left click
	event.source.parent.getComponent('Paintable Canvas').leftClick = True
elif event.button == 3: # right click
	event.source.parent.getComponent('Paintable Canvas').rightClick = True

```

...and add this script to the same component's mouseReleased event handler:

```python
if event.button == 1:
	event.source.parent.getComponent('Paintable Canvas').leftClick = False
elif event.button == 3:
	event.source.parent.getComponent('Paintable Canvas').rightClick = False

```

```python

```

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [May 1, 2024, 2:29pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/9 "2024-05-01T14:29:47Z")

</div>

**Gaming Applications:**

I was showing off the paintable canvas to my son and simultaneous showing him some practical applications for the vector math he was studying in trigonometry class. Things probably got a little carried away, and Bananas! was born. The whole game is rendered by a single paintable canvas, so there are a lot of cool hacks in there: slider controls, switches, buttons, animations, etc.

> [@Introducing Bananas!](https://forum.inductiveautomation.com/t/introducing-bananas/89767):
>
> A single or multiplayer video game for Vision. To Play: Select whether each player will be a robot or a human. Select how many victories are required to win the game, and adjust the gravity if desired. Finally, click the 'Start Game' button. Object of the game: Hit the other monkey with a banana by adjusting the angle and velocity of the throw. The game can be played with a single player, two players, or it's also possible to make both players virtual and just watch the monkeys fight it out. …

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/b/3/b381dcec18a6080c0cebb751f1b879f0e13e403d.png)

I had already constructed some of the ground work this a couple of years ago when I made this post:

> [@Add script to dynamically created objects](https://forum.inductiveautomation.com/t/add-script-to-dynamically-created-objects/64215/15):
>
> Just for fun:

That code includes moving dynamically created shapes around inside a canvas with a mouse, and it has various animation concepts such as momentum, reflection, etc.

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [May 4, 2024, 12:42am UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/10 "2024-05-04T00:42:14Z")

</div>

**Keyboard Controls:**

It's possible that some wouldn't find this intuitive because simply typing code into the key event handlers for the paintable canvas doesn't work. To enable the controls, the paintable canvas has to be given focus first. An easy and intuitive way to accomplish this is to simply add `event.source.requestFocusInWindow()` to the mouse clicked event handler. Then, the key event handlers will work once the canvas has been clicked on.

**Example keyPressed script:**

```python
# A timer component could be started with the keyPressed event handler
# ...to conduct sustained repetative presses when a key is held down,
# ...and the key released event handler would stop and reset the timer.

if event.keyCode == event.VK_UP:
	print 'UP'
elif event.keyCode == event.VK_DOWN:
	print 'DOWN'
elif event.keyCode == event.VK_ENTER:
	print 'ENTER'
elif event.keyCode == event.VK_RIGHT:
	print 'RIGHT'
elif event.keyCode == event.VK_LEFT:
	print 'LEFT'

```

**Edit/Additional information:**  
When painting controls in the canvas, a focus traversal policy can be simulated by putting a focusable component in the container with the canvas. Use the component's focusGained event handler to simply redirect the focus back to the canvas. Inside the canvas itself, use the **focusLost** event handler to traverse painted controls:

```python
if event.cause == event.cause.TRAVERSAL_FORWARD:
	# Drive simulated forward traversal here
elif event.cause == event.cause.TRAVERSAL_BACKWARD:
	# Drive simulated reverse traversal here

```

**Note:** It could seem like the `requestFocusInWindow` could be called directly from the `focusLost` event, eliminating the need for some arbitrary focusable component, but without it, there will be nothing for the real focus traversal policy to tab to, and consequently, there is no guarantee that the `focusLost` event will occur.

Tangibly, I'm not aware of a simple way to detect or consume the tab key events in Vision, so I imagine this approach could easily be adapted for use cases with other components where it is desirable to mute or manipulate tab actions.

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [June 23, 2024, 4:48am UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/11 "2024-06-23T04:48:11Z")

</div>

**Using the paintable canvas as an overlay:**

![PaintableCanvasFloatingOutlineDemo](https://us1.discourse-cdn.com/inductiveautomation/original/3X/d/b/db35740bde6ed1da2cc04e3f32d44cc4b28fd6dc.gif)

> [@Component outline on different z-order than component](https://forum.inductiveautomation.com/t/component-outline-on-different-z-order-than-component/92128/16):
>
> The paintable canvas can be used as an overlay to provide this effect without any regard to z-order or component positioning. Simply overlay the entire container with a paintable canvas, and use the canvas's mouse moved event to detect when the cursor intersects a component, so a top level outline can be painted around it: Example: Add the following custom properties to the canvas: [image] Add the following custom methods to the canvas: 
> 
> > **passMouseEvent**
> >
> > #def passMouseEvent(self, event): "…

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [July 16, 2024, 12:55am UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/12 "2024-07-16T00:55:34Z")

</div>

Below is a master list of all possible string arguments for `system.gui.color()` _ **assigned to a variable for use in scripting functions** _. I originally created this for the random confetti generator I developed for [BANANAS Version 2](https://forum.inductiveautomation.com/t/bananas-version-2/92719), but it's been coming in quite handy elsewhere, so I'm posting it here:

```python
possibleStringColors = [
	#String, #Hex #RGB
	'AliceBlue', # #F0F8FF (240, 248, 255)
	'AntiqueWhite', # #FAEBD7 (250, 235, 215)
	'Aqua', # #00FFFF (0, 255, 255)
	'Aquamarine', # #7FFFD4 (127, 255, 212)
	'Azure', # #F0FFFF (240, 255, 255)
	'Beige', # #F5F5DC (245, 245, 220)
	'Bisque', # #FFE4C4 (255, 228, 196)
	'Black', # #000000 (0, 0, 0)
	'BlanchedAlmond', # #FFEBCD (255, 235, 205)
	'Blue', # #0000FF (0, 0, 255)
	'BlueViolet', # #8A2BE2 (138, 43, 226)
	'Brown', # #A52A2A (165, 42, 42)
	'BurlyWood', # #DEB887 (222, 184, 135)
	'CadetBlue', # #5F9EA0 (95, 158, 160)
	'Chartreuse', # #7FFF00 (127, 255, 0)
	'Chocolate', # #D2691E (210, 105, 30)
	'Clear', # #FFFFFF (255, 255, 255, 0)
	'Coral', # #FF7F50 (255, 127, 80)
	'CornflowerBlue', # #6495ED (100, 149, 237)
	'Cornsilk', # #FFF8DC (255, 248, 220)
	'Crimson', # #DC143C (220, 20, 60)
	'Cyan', # #00FFFF (0, 255, 255)
	'DarkBlue', # #00008B (0, 0, 139)
	'DarkCyan', # #008B8B (0, 139, 139)
	'DarkGoldenRod', # #B8860B (184, 134, 11)
	'DarkGrey', # #A9A9A9 (169, 169, 169)
	'DarkGreen', # #006400 (0, 100, 0)
	'DarkKhaki', # #BDB76B (189, 183, 107)
	'DarkMagenta', # #8B008B (139, 0, 139)
	'DarkOliveGreen', # #556B2F (85, 107, 47)
	'Darkorange', # #FF8C00 (255, 140, 0)
	'DarkOrchid', # #9932CC (153, 50, 204)
	'DarkRed', # #8B0000 (139, 0, 0)
	'DarkSalmon', # #E9967A (233, 150, 122)
	'DarkSeaGreen', # #8FBC8F (143, 188, 143)
	'DarkSlateBlue', # #483D8B (72, 61, 139)
	'DarkSlateGrey', # #2F4F4F (47, 79, 79)
	'DarkTurquoise', # #00CED1 (0, 206, 209)
	'DarkViolet', # #9400D3 (148, 0, 211)
	'DeepPink', # #FF1493 (255, 20, 147)
	'DeepSkyBlue', # #00BFFF (0, 191, 255)
	'DimGrey', # #696969 (105, 105, 105)
	'DodgerBlue', # #1E90FF (30, 144, 255)
	'Feldspar', # #D19275 (209, 146, 117)
	'FireBrick', # #B22222 (178, 34, 34)
	'FloralWhite', # #FFFAF0 (255, 250, 240)
	'ForestGreen', # #228B22 (34, 139, 34)
	'Fuchsia', # #FF00FF (255, 0, 255)
	'Gainsboro', # #DCDCDC (220, 220, 220)
	'GhostWhite', # #F8F8FF (248, 248, 255)
	'Gold', # #FFD700 (255, 215, 0)
	'GoldenRod', # #DAA520 (218, 165, 32)
	'Grey', # #808080 (128, 128, 128)
	'Green', # #008000 (0, 128, 0)
	'GreenYellow', # #ADFF2F (173, 255, 47)
	'HoneyDew', # #F0FFF0 (240, 255, 240)
	'HotPink', # #FF69B4 (255, 105, 180)
	'IndianRed', # #CD5C5C (205, 92, 92)
	'Indigo', # #4B0082 (75, 0, 130)
	'Ivory', # #FFFFF0 (255, 255, 240)
	'Khaki', # #F0E68C (240, 230, 140)
	'Lavender', # #E6E6FA (230, 230, 250)
	'LavenderBlush', # #FFF0F5 (255, 240, 245)
	'LawnGreen', # #7CFC00 (124, 252, 0)
	'LemonChiffon', # #FFFACD (255, 250, 205)
	'LightBlue', # #ADD8E6 (173, 216, 230)
	'LightCoral', # #F08080 (240, 128, 128)
	'LightCyan', # #E0FFFF (224, 255, 255)
	'LightGoldenRodYellow', #	#FAFAD2 (250, 250, 210)
	'LightGreen', # #90EE90 (144, 238, 144)
	'LightGrey', # #D3D3D3 (211, 211, 211)
	'LightPink', # #FFB6C1 (255, 182, 193)
	'LightSalmon', # #FFA07A (255, 160, 122)
	'LightSeaGreen', # #20B2AA (32, 178, 170)
	'LightSkyBlue', # #87CEFA (135, 206, 250)
	'LightSlateBlue', # #8470FF (132, 112, 255)
	'LightSlateGrey', # #778899 (119, 136, 153)
	'LightSteelBlue', # #B0C4DE (176, 196, 222)
	'LightYellow', # #FFFFE0 (255, 255, 224)
	'Lime', # #00FF00 (0, 255, 0)
	'LimeGreen', # #32CD32 (50, 205, 50)
	'Linen', # #FAF0E6 (250, 240, 230)
	'Magenta', # #FF00FF (255, 0, 255)
	'Maroon', # #800000 (128, 0, 0)
	'MediumAquaMarine', # #66CDAA (102, 205, 170)
	'MediumBlue', # #0000CD (0, 0, 205)
	'MediumOrchid', # #BA55D3 (186, 85, 211)
	'MediumPurple', # #9370DB (147, 112, 219)
	'MediumSeaGreen', # #3CB371 (60, 179, 113)
	'MediumSlateBlue', # #7B68EE (123, 104, 238)
	'MediumSpringGreen', # #00FA9A (0, 250, 154)
	'MediumTurquoise', # #48D1CC (72, 209, 204)
	'MediumVioletRed', # #C71585 (199, 21, 133)
	'MidnightBlue', # #191970 (25, 25, 112)
	'MintCream', # #F5FFFA (245, 255, 250)
	'MistyRose', # #FFE4E1 (255, 228, 225)
	'Moccasin', # #FFE4B5 (255, 228, 181)
	'NavajoWhite', # #FFDEAD (255, 222, 173)
	'Navy', # #000080 (0, 0, 128)
	'OldLace', # #FDF5E6 (253, 245, 230)
	'Olive', # #808000 (128, 128, 0)
	'OliveDrab', # #6B8E23 (107, 142, 35)
	'Orange', # #FFA500 (255, 165, 0)
	'OrangeRed', # #FF4500 (255, 69, 0)
	'Orchid', # #DA70D6 (218, 112, 214)
	'PaleGoldenRod', # #EEE8AA (238, 232, 170)
	'PaleGreen', # #98FB98 (152, 251, 152)
	'PaleTurquoise', # #AFEEEE (175, 238, 238)
	'PaleVioletRed', # #DB7093 (219, 112, 147)
	'PapayaWhip', # #FFEFD5 (255, 239, 213)
	'PeachPuff', # #FFDAB9 (255, 218, 185)
	'Peru', # #CD853F (205, 133, 63)
	'Pink', # #FFC0CB (255, 192, 203)
	'Plum', # #DDA0DD (221, 160, 221)
	'PowderBlue', # #B0E0E6 (176, 224, 230)
	'Purple', # #800080 (128, 0, 128)
	'Red', # #FF0000 (255, 0, 0)
	'RosyBrown', # #BC8F8F (188, 143, 143)
	'RoyalBlue', # #4169E1 (65, 105, 225)
	'SaddleBrown', # #8B4513 (139, 69, 19)
	'Salmon', # #FA8072 (250, 128, 114)
	'SandyBrown', # #F4A460 (244, 164, 96)
	'SeaGreen', # #2E8B57 (46, 139, 87)
	'SeaShell', # #FFF5EE (255, 245, 238)
	'Sienna', # #A0522D (160, 82, 45)
	'Silver', # #C0C0C0 (192, 192, 192)
	'SkyBlue', # #87CEEB (135, 206, 235)
	'SlateBlue', # #6A5ACD (106, 90, 205)
	'SlateGrey', # #708090 (112, 128, 144)
	'Snow', # #FFFAFA (255, 250, 250)
	'SpringGreen', # #00FF7F (0, 255, 127)
	'SteelBlue', # #4682B4 (70, 130, 180)
	'Tan', # #D2B48C (210, 180, 140)
	'Teal', # #008080 (0, 128, 128)
	'Thistle', # #D8BFD8 (216, 191, 216)
	'Tomato', # #FF6347 (255, 99, 71)
	'Transparent', # #FFFFFF (255, 255, 255, 0)
	'Turquoise', # #40E0D0 (64, 224, 208)
	'Violet', # #EE82EE (238, 130, 238)
	'VioletRed', # #D02090 (208, 32, 144)
	'Wheat', # #F5DEB3 (245, 222, 179)
	'White', # #FFFFFF (255, 255, 255)
	'WhiteSmoke', # #F5F5F5 (245, 245, 245)
	'Yellow', # #FFFF00 (255, 255, 0)
	'YellowGreen' # #9ACD32 (154, 205, 50)
		]

```

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [May 23, 2026, 1:06pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/14 "2026-05-23T13:06:30Z")

</div>

# Gear Animations

![GearRecording](https://us1.discourse-cdn.com/inductiveautomation/original/3X/3/8/38f9c9fda4de2e5df1c61bb98d325a36982490ac.gif)

**Create a library script called animation, and add the following function to generate a basic gear shape in a paintable canvas:**

```python
from java.awt import BasicStroke, Polygon
from java.lang.Math import sin, cos, PI

def paintGear(graphics, centerX, centerY, gearDiameter, toothHeight, teethCount, rotationAngle, gearColor):
	'''
	Arguments:
		graphics: The java2D graphics option from the repaint event that is being used to generate this animation
		centerX: The x coordinate of the center of the gear
		centerY: The y coordinate for the center of the gear
		gearDiameter: The widest distance across the center of the gear to the tops of the teeth
		innerRadius: The distance from the center of the gear to the base of the teeth
		teethCount: The number of teeth to be painted on the gear
		rotationAngle: The angle of rotation in radians
		gearColor: A java.awt.Color used to fill the body of the gear
	
	Returns:
		None
	
	Overview:
		Calculates all the corners for the teeth, and adds them to a polygon shape to generate a single gear object that can be painted and outlined
	'''
	graphics.stroke = BasicStroke(2)
	gear = Polygon() # Obviously ~ lol
	
	# Calculate the distance from the center of the gear to bottom and top of each tooth
	outerRadius = gearDiameter / 2
	innerRadius = outerRadius - toothHeight
	
	# Java requries radians, but the frames of this animation are divided up into degrees,
	# ...so divide (2 PI) [the radian equivilant of 360 degrees] by the number of teeth
	# ...to get the angle needed to space the teeth evenly around the gear
	toothAngle = (2 * PI) / teethCount
	
	# Slightly offset the angles of the inner and outer points
	# ...to add seemingly uniform flats to the end and base of the gear
	outerHobAngleOffset = .4	# I'm using the word Hob because I once worked in a machine shop,
	innerHobAngleOffset = .1	# ...and the machine that cut the gears was called a gear hobber
	
	# Calculate the (x, y) coordinates needed to draw the gear, and add them to the polygon object
	for tooth in range(teethCount):
		
		# Calculate the current iteration's tooth angle, and offset it by the given rotation anle
		startingAngle = rotationAngle + tooth * toothAngle
		
		
		# Create a list of 6 polar coordinates using the inner and outer radii
		# ...combined with the calculated angle of each tooth
		# ...slightly offset to create little flats at the top of each tooth and at the bottom between the teeth
		polarCoordinates = [(innerRadius, startingAngle),										
			(innerRadius, startingAngle + toothAngle * innerHobAngleOffset),
			
			(outerRadius, startingAngle + toothAngle * outerHobAngleOffset),			
			(outerRadius, startingAngle + toothAngle * (1 - outerHobAngleOffset)),
			
			(innerRadius, startingAngle + toothAngle * (1 - innerHobAngleOffset)),
			(innerRadius, startingAngle + toothAngle)]
		
		# Use the polar to cartesian equations to convert the polar coordinates
		# ...to the required (x, y) coordinates of the Polygon
		for radius, angle in polarCoordinates:
			x = int(centerX + cos(angle) * radius)
			y = int(centerY + sin(angle) * radius)
			gear.addPoint(x, y)
	
	# Set the graphics color for the body of the gear and paint the polygon
	graphics.color = gearColor
	graphics.fillPolygon(gear)
	
	# Draw an outline around the plygon
	graphics.color = system.gui.color(48, 48, 48) # Black was too much on the center hole, so I've slightly greyed it
	graphics.drawPolygon(gear)
	
	# Add a center hole to the polygon (x, y, width, height)
	centerHoleDiameter = 14 # This should be an even number since it's used to offset the x and y coordinates to center this shape on the gear
	centerHoleX = centerX - (centerHoleDiameter / 2)	# A diameters is easier for me to visialize, but it has to be divided by two
	centerHoleY = centerY - (centerHoleDiameter / 2)	# ...and converted to a radius to position the hole correctly with the fillOval method
	graphics.fillOval(centerHoleX, centerHoleY, centerHoleDiameter, centerHoleDiameter)

```

**To generate the gears depicted in the video above, also add this function the animation library:**

```python
def gratuitousExample(graphics, rotationDegree, setupValueX = 50, setupValueY = 50, setupValueDiameter = 100):
	'''
	Arguments:
		graphics: The java2D graphics option from the repaint event that is being used to generate this animation
		
		rotaionDegree: An integer from 0 to 359 that represents which gear angle is being painted during this frame of the animation
		
		[setupValueX, ...Y, ...Diameter]: Optional integers that can be used to position a new gear.
			...Defaults to a gear with a 100 pixel diameter positioned in the top left corner of the canvas
		
	Returns:
		None
	
	Overview:
		Defines the parameters needed for the paintGear library script to paint and aninmate various gears
	'''
	# All gears should have the same type of tooth if they are to mesh together perfectly
	# I've gone around in circles on this trying to decide what will be the most intuiive for developers wanting to use this function,
	# ...and presently, I feel like height and width is easier for us to visualize than the actual machinist terms and methods for measuring gear teeth
	toothHeight = 12
	toothWidth = 20
	
	# *********************************************************
	# Repeat this sequence as needed for any subsequent gears
	# Parameters for the driving gear
	gearOneCenterX = 100 # Center x coordinate
	gearOneCenterY = 100 # Center y coordinate
	gearOneDiameter = 185 # How big accross the gear should be
	gearOneCircumference = PI * gearOneDiameter # Calculate the circumference of the gear
	gearOneTeethCount = int(gearOneCircumference / toothWidth)	# ...and use it to determine the tooth count
	gearOneRotation = rotationDegree * (PI / 180.0) # Convert the degree integer to radians [rotationDegree can also be referred to as the frame of the animation]
	gearOneColor = system.gui.color('darkgrey') # This is the fill color for the body of the gear
	# See the last gear for an example of how to use the given setup parameters
	# *********************************************************
	
	# Parameters for the gearTwo gear
	gearTwoCenterX = 201
	gearTwoCenterY = 54
	gearTwoDiameter = 55
	gearTwoCircumference = PI * gearTwoDiameter
	gearTwoTeethCount = int(gearTwoCircumference / toothWidth)
	gearTwoRotation = -gearOneRotation * (float(gearOneTeethCount) / gearTwoTeethCount)	# Invert the angle since this gear moves opposite the gearOne gear, and ratio the angle by the relative number of teeth
	gearTwoColor = system.gui.color('grey')
		
	# Parameters for the gearThree gear
	gearThreeCenterX = 282
	gearThreeCenterY = 90
	gearThreeDiameter = 140
	gearThreeCircumference = PI * gearThreeDiameter
	gearThreeTeethCount = int(gearThreeCircumference / toothWidth)
	gearThreeRotation = gearOneRotation * (float(gearOneTeethCount) / gearThreeTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearThreeColor = system.gui.color('darkgrey')
	
	# Parameters for the gearFour gear
	gearFourCenterX = 449
	gearFourCenterY = 216
	gearFourDiameter = 300
	gearFourCircumference = PI * gearFourDiameter
	gearFourTeethCount = int(gearFourCircumference / toothWidth)
	gearFourRotation = -gearOneRotation * (float(gearOneTeethCount) / gearFourTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearFourColor = system.gui.color('grey')

	# Parameters for the gearFive gear
	gearFiveCenterX = 263
	gearFiveCenterY = 254
	gearFiveDiameter = 100
	gearFiveCircumference = PI * gearFiveDiameter
	gearFiveTeethCount = int(gearFiveCircumference / toothWidth)
	gearFiveRotation = gearOneRotation * (float(gearOneTeethCount) / gearFiveTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearFiveColor = system.gui.color('darkgrey')
	
	# Parameters for the gearSix gear
	gearSixCenterX = 130
	gearSixCenterY = 273
	gearSixDiameter = 188
	gearSixCircumference = PI * gearSixDiameter
	gearSixTeethCount = int(gearSixCircumference / toothWidth)
	gearSixRotation = -gearOneRotation * (float(gearOneTeethCount) / gearSixTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearSixColor = system.gui.color('grey')

	# Parameters for the gearSeven gear
	gearSevenCenterX = 509
	gearSevenCenterY = 46
	gearSevenDiameter = 80
	gearSevenCircumference = PI * gearSevenDiameter
	gearSevenTeethCount = int(gearSevenCircumference / toothWidth)
	gearSevenRotation = gearOneRotation * (float(gearOneTeethCount) / gearSevenTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearSevenColor = system.gui.color('darkgrey')

	# Parameters for the gearEight gear
	gearEightCenterX = 454
	gearEightCenterY = 37
	gearEightDiameter = 50
	gearEightCircumference = PI * gearEightDiameter
	gearEightTeethCount = int(gearEightCircumference / toothWidth)
	gearEightRotation = -gearOneRotation * (float(gearOneTeethCount) / gearEightTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearEightColor = system.gui.color('grey')

	# Parameters for the gearNine gear
	gearNineCenterX = 401
	gearNineCenterY = 37
	gearNineDiameter = 70
	gearNineCircumference = PI * gearNineDiameter
	gearNineTeethCount = int(gearNineCircumference / toothWidth)
	gearNineRotation = gearOneRotation * (float(gearOneTeethCount) / gearNineTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearNineColor = system.gui.color('darkgrey')

	# *********************************************************
	# New Gear setup example:
	# I found that generating the optional arguments with spinner components worked quite well
	gearTenCenterX = setupValueX # Use the given setup parameters to position the gear at center point X
	gearTenCenterY = setupValueY # Use the given setup parameters to position the gear at center point Y
	gearTenDiameter = setupValueDiameter # Use the given setup parameters to adjust the diameter in real time as needed to fit the gear
	gearTenCircumference = PI * gearTenDiameter
	gearTenTeethCount = int(gearTenCircumference / toothWidth)
	gearTenRotation = -gearOneRotation * (float(gearOneTeethCount) / gearTenTeethCount) # Take the angle of the gearOne [driving] gear, and ratio this gear's angle by its relative number of teeth
	gearTenColor = system.gui.color('orange')	# Using an odd color to depict the setup gear helps as well if there are many other gears in the animation
	# *********************************************************

	# Paint the gears
	paintGear(graphics, gearOneCenterX, gearOneCenterY, gearOneDiameter, toothHeight, gearOneTeethCount, gearOneRotation, gearOneColor)
	paintGear(graphics, gearTwoCenterX, gearTwoCenterY, gearTwoDiameter, toothHeight, gearTwoTeethCount, gearTwoRotation, gearTwoColor)
	paintGear(graphics, gearThreeCenterX, gearThreeCenterY, gearThreeDiameter, toothHeight, gearThreeTeethCount, gearThreeRotation, gearThreeColor)
	paintGear(graphics, gearFourCenterX, gearFourCenterY, gearFourDiameter, toothHeight, gearFourTeethCount, gearFourRotation, gearFourColor)
	paintGear(graphics, gearFiveCenterX, gearFiveCenterY, gearFiveDiameter, toothHeight, gearFiveTeethCount, gearFiveRotation, gearFiveColor)
	paintGear(graphics, gearSixCenterX, gearSixCenterY, gearSixDiameter, toothHeight, gearSixTeethCount, gearSixRotation, gearSixColor)
	paintGear(graphics, gearSevenCenterX, gearSevenCenterY, gearSevenDiameter, toothHeight, gearSevenTeethCount, gearSevenRotation, gearSevenColor)
	paintGear(graphics, gearEightCenterX, gearEightCenterY, gearEightDiameter, toothHeight, gearEightTeethCount, gearEightRotation, gearEightColor)
	paintGear(graphics, gearNineCenterX, gearNineCenterY, gearNineDiameter, toothHeight, gearNineTeethCount, gearNineRotation, gearNineColor)
	paintGear(graphics, gearTenCenterX, gearTenCenterY, gearTenDiameter, toothHeight, gearTenTeethCount, gearTenRotation, gearTenColor)

```

**For this example, add the following components to a Vision window:**  
• A paintable canvas that is at least 620 x 400 pixels.  
_... **Note:** If you need your gear animations to scale to any size, see [this post](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/7)._  
• A timer component to drive the animation  
• Three spinner components for gear setup  
• Four labels to make the spinner purposes clear

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/7/8/78d0a627cb1abbe75d56cdb72c47a8ebccb834ab.png)  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/d/6/d6ee9ed39e4b3fe2d3f5be9a2df10c86f7fb9e62.png)

**Add the following custom properties to the paintable canvas:**  
• frame _[integer]_  
• setupValueDiameter _[integer]_  
• setupValueX _[integer]_  
• setupValueY _[integer]_

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/9/8/98e9efb2ed728d9871fddee7b52424e7edb1244e.png)

**Bind the custom properties to the following component properties:**  
• frame -- _Bound to the timer component's value property_  
• setupValueX -- _Bound to the first spinner component's intValue property_  
• setupValueY -- _Bound to the second spinner component's intValue property_  
• setupValueDiameter -- _Bound to the third spinner component's intValue property_

**Setup the timer component in the following way, so it counts up almost endlessly and changes its value property approximately every 10 milliseconds**

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/4/a/4a159faaf6de628795f839ee2d7ae9430d3831b0.png)

**Add the following script to the repaint event handler of the paintable canvas:**

```python
# The animation is drawn using 360 frames [one for each degree],
# ...so the animation script will need to know which frame to paint
graphics = event.graphics
frame = event.source.frame % 360
animation.gratuitousExample(graphics, frame, event.source.setupValueX, event.source.setupValueY, event.source.setupValueDiameter)

```

* * *

**Set the spinner min, max, and initial values:**  
• All spinners should have an arbitrarily large value to ensure they never interfere with your ability to adjust the position and size of the gears. I set mine to 100 million  
• X and Y setup spinners can have a minimum value of 0, but I use 25 to ensure the gears are always beyond the top and left sides of the canvas  
• The diameter spinner's minimum value **MUST** be big enough to ensure at least one tooth can appear on the gear. Otherwise, a zero division error will occur in the script I've provided. I set mine to 50, so the gear will never start out overlapping the left and top edges of the canvas  
• Set the integer values of each spinner to some arbitrary number between the min and max values, or a red overlay will appear over the spinner at run time. For the example setup gear, I've found the following values position the gear well in this animation: X = 348, Y = 28, and Diameter = 57

**Start preview mode, and toggle the animation on or off as needed using the timer component's running property**  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/0/500daa9c4ef5548dcecbe6f3bc4885fa355ccb9e.png)

_ **New Gear Setup Notes:** _  
• Once a setup gear is positioned correctly, replace the variable names in the library script with the integer values from the spinners.  
• To add another gear, simply create a new parameter set in the library script, and use the optional setup position variables to repeat the setup process  
• Obviously, the spinners and their labels can be deleted once the setup process for the animation has been completed.  
• See the example function's code comments for additional notes about gear setup

_ **Here is an export of the example project in case anybody wants to play around with it:** _  
[gearExample.zip](https://forum.inductiveautomation.com/uploads/short-url/8PcTaEja4K9TiaiU6qYqGSCPCl4.zip) (20.1 KB)

I imagine a lot can be done to improve the level of detail in the gears, such as adding rings to the outside or hub rings around the center hole to give the gears a more 3D look. It would also be nice to add some trapezoidal shapes to give the gears a more spoked appearance,  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/6/5/65c8850bc74ddf6b8bf3fdc136ad96c721a6b70c.png)  
...so if anybody adds something that improves this example, please reply to this thread, and show us how it's done.

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [May 29, 2026, 3:03pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/15 "2026-05-29T15:03:49Z")

</div>

# Flag Animations

![WavyOldGlory](https://us1.discourse-cdn.com/inductiveautomation/original/3X/b/e/be396e08a0b5dcf2805a3e4642fe9174dee72ade.gif)

The approach in this post will work with just about any flag you can come up with, but for reference, the flag I'm animating was originally created and posted here:

[Holiday Paintable Canvas Challenge: Old Glory](https://forum.inductiveautomation.com/t/holiday-paintable-canvas-challenge/112691/9)

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/8/58c74466e561c4892917a8c8168913bfd231ade9.png)

**Prerequisite Knowledge:**  
• A timer will need to be set up with its value property bound to a custom frame property on the paintable canvas  
• Imports are going to be required to make this work, so for efficiency, this technique should be set up in the script library and called from the repaint event  
_ **Reference this post for a detailed tutorial on how implement these two prerequisites:** _  
[Paintable Canvas Hacks: Gear Animations](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/14)

That said, here is a quick tutorial for creating wavy flag animations in Ignition  
**Generating sinusoidal waves:**

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/1/f/1f0bf7989cbe8cab319d55595d26d0518740e442.jpeg)

Amplitude and wavelength _[aka period]_ are the only two things that are needed to draw a consistent sine wave in Ignition

```python
amplitude = 14
waveLength = 240

```

As the line is being formed, simply calculate the fraction of the wavelength that has been traversed by the given x coordinate:

```python
cyclePosition = x / float(waveLength)

```

In the case of our flag, we will want the waves to continuously move horizontally, so it looks like the wave is being pushed by the wind. This is where our timer value comes in. It will be used to generate a phase variable that will shift the x coordinates horizontally:

```python
frame = event.source.frame
phase = -(frame % waveLength) # The illusion of the flag being pushed by the wind is created by adjusting the starting point of the sinusoid
												# ...make the phase shift negative to simulate left to right movement
cyclePosition = (x + phase) / float(waveLength)	# Convert the phased x position into its fractional position within the wave cycle

```

To get the y coordinate that corresponds to a given x coordinate in a sine wave, convert the cycle position to radians, take the sin of the resultant value, and multiply it by the amplitude:

```python
y = amplitude * sin(cyclePosition * (PI * 2)) # Evaluate the sine wave at the current cycle position and scale it using the amplitude

```

With a timer value driving the wave, the above code will produce a nice left to right flowing wave of equal amplitude, but that won't work for a flag because one end will be anchored to a pole, and the other end will be free to the wind. Consequently, the result is a dynamic amplitude that is hardly noticeable at one end while appearing quite significant at the other. Therefore, the amplitude will need to be split into a minimum amplitude variable for the anchored end and a maximum additional amplitude parameter that can be scaled according to a given x coordinate's distance from the anchored end.

Putting it together to calculate the x and y coordinates from one end of a wavy flag to the other, it looks like this:

```python
# Animated wave parameters
# ...Note: The side of the flag that is anchored to the pole naturally won't be able to move up and down as much as the side of the flag that's out in the wind,
# ...so the amplitude parameter is split. The amplitude will be ratiod across the flag to from the pole to the free end
# ...with the minWaveAmp at the pole and incrementally added amplitude until all the maxAdditionalAmplitude is added in at the last pixel on the opposite end of the flag
frame = event.source.frame # This is a custom property that's bound to a timer value parameter and is used to drive the flag waving amimation
minWaveAmp = 0.5 # The amount of vertical flag movemnt allowed at the flag pole
maxAdditionalAmplitude = 14 # The amount of additional vertical flag movement that is allowed at the free side of the flag
waveLength = 240 # The math nerd in me wants to call this the period
phase = -(frame % waveLength) # The illusion of the flag being pushed by the wind is created by adjusting the starting point of the sinusoid
											# ...make the phase shift negative to simulate left to right movement
for x in xrange(flagWidth + 1): # Iterate across every pixel along the width of the flag including the final edge
	additionalAmplitude = maxAdditionalAmplitude * (float(x) / flagWidth)	# Calculate what percentage of the maxAdditionalAmplitude to add based on the position moving from the attached end of the flag to the free end of the flag
	totalAmplitude = minWaveAmp + additionalAmplitude # Combine the minimum amplitude with the position based additional amplitude
	cyclePosition = (x + phase) / float(waveLength) # Convert the phased x position into its fractional position within the wave cycle
	y = totalAmplitude * sin(cyclePosition * (PI * 2)) # Evaluate the sine wave at the current cycle position and scale it using the calculated amplitude

```

Here is the full script that was used in the repaint event that generated the depicted flag animation:  
_(Click the right pointing arrow to show the code)_

> **Source Code**
>
> ```python
> from java.awt import BasicStroke
> from java.awt.geom import GeneralPath
> from java.lang.Math import PI, sin
> graphics = event.graphics
> 
> # Flag pole Parameters
> baseRGB = 165 # The darkest shaded portion of the pole
> flagPoleWidth = 20 # 
> flagBallDiameter = 28	# 
> flagGap = 2 # This is the space between the flag pole and the flag (In pixels)
> flagPoleHeight = 500 # An arbitrarily large number that should more than overlap the bottom of the canvas
> 
> # Flag Parameters (In pixels)
> stripeHeight = 14 #
> flagWidth = 345 #
> totalStripes = 13 # The American flag has 13 stripe representing the original 13 colonies
> flagHeight = totalStripes * stripeHeight	#
> cantonWidth = 138 # The technical name for the blue rectangle on the American flag is the canton, but it's also know colloqually as the union
> cantonHeight = stripeHeight * 7 # When this is sized correctly, it covers the first seven stripes
> 
> # Flag Colors
> oldGloryRed = system.gui.color(179, 25, 66)	#
> oldGloryWhite = system.gui.color('white')	#
> oldGloryBlue = system.gui.color(10, 49, 97)	#
> borderColor = system.gui.color(0, 0 ,0, 40)	# Tranparancy creates a nice shadowy effect and minimizes aliasing
> 
> # Animated wave parameters
> # ...Note: The side of the flag that is anchored to the pole naturally won't be able to move up and down as much as the side of the flag that's out in the wind,
> # ...so the amplitude parameter is split. The amplitude will be ratiod across the flag to from the pole to the free end
> # ...with the minWaveAmp at the pole and incrementally added amplitude until all the maxAdditionalAmplitude is added in at the last pixel on the opposite end of the flag
> frame = event.source.frame # This is a custom property that's bound to a timer value parameter and is used to drive the flag waving amimation
> minWaveAmp = 0.5 # The amount of vertical flag movemnt allowed at the flag pole
> maxAdditionalAmplitude = 14 # The amount of additional vertical flag movement that is allowed at the free side of the flag
> waveLength = 240 # The math nerd in me wants to call this the period
> phaseRate = 6 # This could be called windSpeed, and this could also be controlled by simply making the timer count in larger increments than 1
> phase = -(frame % waveLength) * phaseRate	# The illusion of the flag being pushed by the wind is created by adjusting the starting point of the sinusoid
> # ...make the phase shift negative to simulate left to right movement
> 
> # For simplicity, everything is drawn from (0, 0) and the final positions of objects are adjusted via translation
> graphics.translate(flagPoleWidth - flagGap, flagBallDiameter)
> 
> # Paint the shaft of the pole
> for index in xrange(flagPoleWidth):
> rgb = baseRGB + (2 * index) # Adjust the color from dark to bright with each iteration
> graphics.color = system.gui.color(rgb, rgb, rgb) # ...while simultaneously reducing the stroke width
> graphics.stroke = BasicStroke(flagPoleWidth - index,	# ...to create a linear gradient effect
> BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER) # The round cap produces a smoother taper
> x1 = x2 = 0 # X doesn't change on a perfectly vertical line
> y1 = flagPoleWidth - index # This is offset ip to make a tapered neck below the ball
> y2 = flagPoleHeight # The number assigned to this variable needs to exceed the lower edge of the canvas for the bottom of the pole to look right
> graphics.drawLine(x1, y1, x2, y2) # Shift the x position up each iteration to create a point for the ball to rest on
> 
> # Paint the ball on the top of pole
> for index in xrange(flagBallDiameter):
> rgb = baseRGB + (2 * index)
> graphics.color = system.gui.color(rgb, rgb, rgb) # Same as the shaft except with a shinking dot instead of a line
> graphics.stroke = BasicStroke(flagBallDiameter - index, # ...to create a spherical gradient effect that perfectly matches the pole
> BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER) #
> graphics.drawLine(0, -(flagPoleWidth / 3), 0, -(flagPoleWidth / 3))	# The y parameters are offset up, so the ball rests on top of the shaft's upper point
> 
> # Adjust the graphics origin for flag painting
> flagXOffset = (flagPoleWidth / 2) + flagGap #
> flagYOffset = flagPoleWidth # This puts the top of the flag right below the base of the flagPole's cone
> graphics.translate(flagXOffset, flagYOffset)	#
> 
> # All of the math needed to calculate the points for the outline and the canton is done while calculating the points for the stripes,
> # ...so rather than having to do all the calculations again later, the relevent coordinates are simply stored in these empty lists
> # ...and used directly for painting later in the script
> outlinePoints = []
> cantonPoints = []
> 
> # Iterate through the 13 stripes that represent the original 13 colonies and paint them
> for stripeIndex in xrange(totalStripes):
> stripe = GeneralPath()
> 	
> # Alternate red and white
> if stripeIndex % 2:
> graphics.color = oldGloryWhite
> else:
> graphics.color = oldGloryRed
> 
> # Whare the top of the stripe would be without wavyness
> flatStripeY = stripeHeight * stripeIndex
> 	
> # No need to do all the math twice, as the top points are calculated, 
> # ...offset the y coordinate by the stripe height and store it with the x coordinate here for later
> # ...when the bottom points are added to the strip generalPath shape
> bottomPoints = []
> 	
> # Top edge of the stripe
> for x in xrange(flagWidth + 1): # Iterate across every pixel along the width of the flag including the final edge
> additionalAmplitude = maxAdditionalAmplitude * (float(x) / flagWidth)	# Calculate what percentage of the maxAdditionalAmplitude to add based on the position moving from the attached end of the flag to the free end of the flag
> totalAmplitude = minWaveAmp + additionalAmplitude # Combine the minimum amplitude with the position based additional amplitude
> cyclePosition = (x + phase) / float(waveLength) # Convert the phased x position into its fractional position within the wave cycle
> waveOffset = totalAmplitude * sin(cyclePosition * (PI * 2)) # Evaluate the sine wave at the current cycle position and scale it using the calculated amplitude
> wavyStripeY = flatStripeY + waveOffset # Apply the current x position's wave offset to the stripe's base Y coordinate
> bottomPoints.insert(0, (x, wavyStripeY + stripeHeight)) # Add the stripe height to calculated y coodinate for the equivalent x coordinate set on the bottom of the stripe, and insert the resultant set into the bottomPoints list
> 
> # Add all of the top stripe points to the outlinePoints list,
> if stripeIndex == 0:
> outlinePoints.append((x, wavyStripeY))
> 
> # ...and if the x coordinate is one of the ones shared by the canton,
> # ...add that coordinate set to the cantonPoints list
> if x < cantonWidth:
> cantonPoints.append((x, wavyStripeY))
> 
> # Set the general path to its starting point
> if x == 0: # Set the general path to its starting point
> stripe.moveTo(x, wavyStripeY)
> continue
> stripe.lineTo(x, wavyStripeY) # Adding each pixel along the top of stripe from left to right
> 		
> # Bottom edge of the stripe	
> for x, y, in bottomPoints:
> stripe.lineTo(x, y) # Adding each pixel along the bottom of the stripe from right to left
> 
> # Add all the bottom pixels of the bottom stripe to the outlinePoints list
> if stripeIndex == totalStripes - 1:
> outlinePoints.append((x, y))
> 		
> # The bottom of the canton shares its bottom edge with the bottom of the 7th stripe [6th zero indexed iteration],
> # ...so add any coordinate set from the bottom of that strip to the cantonPoints list
> # ...if the x coordinate is left of the canton's right edge
> if stripeIndex == 6 and x < cantonWidth:
> cantonPoints.append((x, y))
> 
> # Complete and paint the stripe
> stripe.closePath()	# Creates the left edge of the stripe by connecting the bottom corner to the top
> graphics.fill(stripe)
> 
> # Create a blue rectangle for the 50 stars
> graphics.color = oldGloryBlue
> canton = GeneralPath()
> startingX, startingY = cantonPoints[0]	# Unpack the fist set of coordinates in the cantonPoints list
> canton.moveTo(startingX, startingY) # ...and add a moveTo iteration to the canton path as a starting point for its iterator
> for x, y in cantonPoints: #
> canton.lineTo(x, y) # Unpack each point in the cantonPoints list and add a lineTo iteration to the canton shape
> canton.closePath() # Close the gap from the bottom left corner to the top left corner
> graphics.fill(canton) # ...and fill the completed shape to paint the canton
> 
> # Define all the points needed to make a single star in the top left corner of the canvas
> points = [
> (4, 0),	# Top outer point
> (5, 3),	# Upper right inner point
> (8, 3),	# Upper right outer point
> (5, 5),	# Lower right inner point
> (7, 8),	# Bottom right outer point
> (4, 7),	# Bottom inner point
> (1, 8),	# Bottom left outer point
> (2, 5),	# Lower left inner point
> (0, 3),	# Upper left outer point
> (3, 3)]	# Upper left inner point
> 
> # Separate the x and y coordinates for the top left star into seperate lists for the fillPolygon method,
> # ...and inset the star by an arbitrary margin (This was originally done directly in the points set,
> edgeMargin = 4
> 
> # Switch to white for the stars
> graphics.color = oldGloryWhite
> 
> # These arbitrary spacing parameters were precisely calculated using trial and error
> horizontalSpacing = 24	# The space betwenn the left edge of a star and the left edge of its neighboring star to the right
> verticalSpacing = 10	# The space between the top edge of a row, and the top edge of the row below it
> 
> # The american flag currently has 5 rows of 6 stars and 4 rows of 5 stars for a total 9 rows
> for row in xrange(9):
> starCount = 5 if row % 2 else 6 # Alternate between rows of 6 and 5 stars
> starInset = 12 if row % 2 else 0	# Inset the rows of 5, so the stars appear evenly staggared	
> 
> # Iterate through each star in the given row,
> # ...and calculate a list of X and Y coordinates for each star to pass into the fillPolygon method
> for star in xrange(starCount):
> starXOffset = starInset + (star * horizontalSpacing)
> 	
> # Use the star's horizontal position to calculate how far the flag wave has moved vertically
> waveOffset = (minWaveAmp + maxAdditionalAmplitude * (float(starXOffset) / flagWidth)) * sin(((starXOffset + phase) / float(waveLength)) * PI * 2)
> 	
> # Create a couple of empty lists to store the calculated wave offset star coordinates in
> starXCoordinates = []
> starYCoordinates = []
> 		
> # Unpack each coordinate set within the current star and calculate the necessary positional offsets
> for xCoordinate, yCoordinate in points:
> 			
> # Shift the star according to whether or not it's in a row with 5 or 6 stars to create a proper staggering effect
> modifiedXcoordinate = xCoordinate + edgeMargin + starXOffset
> 			
> # See the first few lines of the stripe painting loop for a detailed breakdown of the waveOffset calculation
> waveOffset = (minWaveAmp + maxAdditionalAmplitude * (float(modifiedXcoordinate) / flagWidth)) * sin(((starXOffset + phase) / float(waveLength)) * PI * 2)
> 			
> # Factor in all the calculated Y parameters to vertically position the star
> modifiedYCoordinate = int(yCoordinate + edgeMargin + (row * verticalSpacing) + waveOffset)
> 		
> # Add the caclulated coordinates to their respective lists
> starXCoordinates.append(modifiedXcoordinate)
> starYCoordinates.append(modifiedYCoordinate)
> 		
> # Paint the star using the coordinate lists
> graphics.fillPolygon(starXCoordinates, starYCoordinates, len(points))
> 
> # Paint a small border around the flag, so it will look complete against white backgrounds.
> graphics.color = borderColor # Without additional anti aliasing logic, this shape looks badly aliased if it's fully opaque, so the border color should have plenty of transparency
> 
> # Create a general path shape for the outline of the flag
> outline = GeneralPath()
> 
> # Unpack the first coordinate set for use as a starting point for the outline shape
> startingX, startingY = outlinePoints[0]
> outline.moveTo(startingX, startingY)
> 
> # Unpack all coordinate sets in the outlinePoints list,
> # and create a shape that can outline the flag
> for x, y in outlinePoints:
> outline.lineTo(x, y)
> outline.closePath() # Connect the bottom left corner of the flag to the top left corner to complete the outline shape
> 
> # Draw the semitransparent outline to complete the flag
> graphics.stroke = BasicStroke(2)
> graphics.draw(outline)
> 
> ```

...or if preferred, here is an export of the window with the above working example:  
[FlagExample.zip](https://forum.inductiveautomation.com/uploads/short-url/aHJuuzuykHgn2KKudYn9CykPNYI.zip) (14.9 KB)

---

<div class="post-metadata">

**Author:** ![paul-griffith](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/paul-griffith/32/37249_2.png) [@paul-griffith](https://forum.inductiveautomation.com/u/paul-griffith)\
**Post date:** [June 1, 2026, 10:42pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/16 "2026-06-01T22:42:07Z")

</div>

Dropping this here just to see if I can successfully nerd-snipe Justin:

> **[Voxel Space](https://s-macke.github.io/VoxelSpace/)**
>
> Terrain rendering algorithm in less than 20 lines of code

---

<div class="post-metadata">

**Author:** ![pturmel](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/pturmel/32/3295_2.png) [@pturmel](https://forum.inductiveautomation.com/u/pturmel)\
**Post date:** [June 1, 2026, 11:10pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/17 "2026-06-01T23:10:17Z")

</div>

Shield Up! Too close to the target zone!

( Someone is sniping at close range with a shotgun. )

---

<div class="post-metadata">

**Author:** ![michael.flagler](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/michael.flagler/32/26272_2.png) [@michael.flagler](https://forum.inductiveautomation.com/u/michael.flagler)\
**Post date:** [June 2, 2026, 12:07am UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/18 "2026-06-02T00:07:26Z")

</div>

I loved that game back in the day!

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [June 16, 2026, 2:31pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/19 "2026-06-16T14:31:16Z")

</div>

# Function for generating evenly spaced points around an ellipse

I used this technique for making perfect circles in [my tutorial about creating gear animations](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/14), but in this tutorial, I want to demonstrate how this approach can be used for oval shapes to add depth to renderings of cylindrical objects.

**The function:**

```python
from java.lang import Math
def getSpacedOvalPoints(points, centerX, centerY, width, height, degreeOffset = 0):
	radiusWidth = width / 2
	radiusHeight = height / 2
	
	# java.lang.Math trigonometric functions requires radians
	startAngle = Math.toRadians(degreeOffset) # ...or (3.14 * degreeOffset) / 180 to eliminate the import
	
	# Set the end angle to 360 degrees more than the starting angle
	endAngle = startAngle + (2 * Math.PI) # This could also be written startAngle + Math.toRadians(360)
	
	# Abort the operation if there aren't at least two points to avoid a potential division error and other wierdness
	if points <= 1:
		yield centerX, centerY
		return
	
	# Iterate through each point, and yield a coordinate set for the current angle and distance from origin
	angleIncrement = (endAngle - startAngle) / float(points - 1)
	for point in xrange(points):
		angle = startAngle + (point * angleIncrement)
		yield ( # Polar-to-Cartesian conversions
			int(centerX + Math.cos(angle) * radiusWidth),
			int(centerY - Math.sin(angle) * radiusHeight))

```

**Example:** _add toppings to a birthday cake_

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/9/e/9eaba4bff2eb70a29aa31f02e3f364f384ec00d7.png)

Using the above function to pass x and y coordinates into a separate function that paints cherries, we can add a ring of cherries to the top of the cake. Furthermore, we can make the cherries in the back slightly smaller than the cherries in the front to make them appear further away by enumerating the coordinates to identify the halfway point:

```python
for index, (x, y) in enumerate(getSpacedOvalPoints(cherryCount, topCenterX, topCenterY, cherryRingWidth, cherryRingHeight)):
	
	# Reduce the cherry size by three pixels if the index is beyond the halfway point [indicating the cherries are on the far side of the ring]
	cherryDiameter = cherrySize if index > halfCherryCount else cherrySize - 3
	
	# Offset the cherry by it's radius, so the cherries are centered on the coordinates returned by the getSpacedOvalPoints function
	cherryRadius = cherryDiameter / 2
	paintCherry(graphics, x - cherryRadius, y - cherryRadius, cherryDiameter)

```

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/f/a/fa0a4b89a6afb35262288bf112e374d9ea60c9b7.png)

With bigger or taller objects that can overlap, a problem can occur when the foreground shapes cover up the background shapes. However, this problem is easily solved by using the getSpacedOvalPoints's optional `degreeOffset` parameter:

```python
# Use an angle offset to adjust the candle positions, so they don't overlap
angleOffset = 12
for index, (x, y) in enumerate(getSpacedOvalPoints(candleCount, topCenterX, topCenterY, candleRingWidth, candleRingHeight, angleOffset)):
	candleX = x - candleWidth / 2
	candleY = y - candleHeight
	
	# Shrink the candle slightly if it's in the back
	if index > halfCandleCount: 
		adjustedWidth = candleWidth
		adjustedHeight = candleHeight
	else:
		adjustedWidth = candleWidth - 2
		adjustedHeight = candleHeight - 4

	paintCandle(graphics, candleX, candleY, adjustedWidth, adjustedHeight, candleBlue)

```

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/4/8/48ae73668a6cbe4bcf67369699c3e9ee75210c25.png)

Obviously, it does not escape my attention that this angle parameter could be supplied from a custom property that is bound to a timer to make the ellipse spin, but that's for another tutorial since cake toppings are not supposed to spin. ~ lol  
 ![SpinningCake](https://us1.discourse-cdn.com/inductiveautomation/original/3X/7/4/74a9c3556e4260f81f99693f3cbe7da0b3321c2b.gif)

If we add something that needs to be layered in, we can apply the split function approach that we've been using to shrink the far away toppings to filter out either the front or the back half of the ellipse. I'll demonstrate this, by layering in dabs of icing on either side of the cherries and around the bottom of the cake:

```python
# Filter out the front icing dabs on top of the cake
for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius)):
	if index < (dabCount / 2):
		paintIcingDab(graphics, icingX, icingY, dabDiameter)

#########
# Paint the cherries here, in between the front and back icing dabs
#########

# Filter out the back icing dabs on top of the cake
for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius)):
	if index > (dabCount / 2):
		paintIcingDab(graphics, icingX, icingY, dabDiameter)

#########
# Paint the candles and anything else on the cake here
# ...before adding the final icing ring around the base of the cake
#########

# Double the density of the dab count for the bottom icing ring
# ...and filter out the back half of the elipse
for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount * 2, topCenterX, topCenterY + cakeHeight, cakeWidth - dabRadius, topHeight - dabRadius)):
	if index > dabCount:
		paintIcingDab(graphics, icingX, icingY, dabDiameter)

```

![CompletedCake](https://us1.discourse-cdn.com/inductiveautomation/original/3X/8/c/8c66dfd7e44001dc66354c8097fc6a2223e9f23d.gif)

One thing that is worth mentioning, but perhaps isn't applicable to this example, is that specific indices can be filtered out if a gap needs to be made in the ellipse. It's as simple as making a list of points to ignore. When I do this, I often find specific indices easier to identify when referenced from the end points or halfway points:

```python
if index not in [0, candleCount - 1, halfCandleCount, halfCandleCount - 1]:
	paintCandle(graphics, candleX, candleY, adjustedWidth, adjustedHeight, candleBlue)

```

As you can see, the `getSpacedOvalPoints` function is quite effective for adding both depth and detail to any cylindrical object. If you find a fun use for it, please come back and share it here.

_ **Lastly, if you want to use this cake somewhere, click the arrow below to see and copy the complete paintable canvas repaint event script that I developed to create this example:** _

> **Source Code**
>
> ```python
> from java.awt import GradientPaint, BasicStroke
> from java.lang import Math
> import random
> 
> graphics = event.graphics
> color = system.vision.color # When using 8.3 or newer
> # color = system.gui.color # When using 8.1 or older
> 
> def paintCake(graphics, x, y, width, height, topHeight, cakePaint, icingColor):
> topOvalHeight = bottomOvalHeight = topHeight # width / 4 (Needs to be a ratio of the width to consistantly look right regardless of size
> sideTopOffset = topOvalHeight / 2
> bottomOvalY = y + height
> icingLengths = [
> int(height / 2.5), 
> int(height / 1.6), 
> int(height / 1.2), 
> int(height / 1.5), 
> int(height / 1.8), 
> int(height / 1.49), 
> int(height / 1.28), 
> int(height / 1.69), 
> int(height / 2.2), 
> int(height / 1.9), 
> int(height / 1.6), 
> int(height / 2.18)]
> icingWidth = cakeWidth / len(icingLengths)
> startIcingX = x + icingWidth / 2
> startIcingY = y + topOvalHeight / 2
> graphics.paint = cakePaint
> graphics.fillRect(x, y + sideTopOffset, width, height)
> graphics.fillOval(x, bottomOvalY, width, bottomOvalHeight)
> graphics.color = icingColor
> graphics.stroke = BasicStroke(icingWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
> for index, icingLength in enumerate(icingLengths):
> x1 = x2 = startIcingX + (index * icingWidth)
> y1 = startIcingY
> y2 = startIcingY + icingLength
> graphics.drawLine(x1, y1, x2, y2)
> 		
> 
> graphics.paint = cakePaint
> graphics.fillOval(x, y, width, topOvalHeight)
> graphics.color = icingColor
> graphics.fillOval(x, y, width, topOvalHeight)
> 	
> def paintCandle(graphics, x, y, width, height, candleColor):
> flameYellow = color(255, 190, 0)
> flameOrange = color(255, 95, 0)
> flameGlow = color(255, 220, 80, 80)
> 	
> wickColor = color(45, 30, 20)
> shadowColor = color(0, 0, 0, 45)
> highlightColor = color(255, 255, 255, 70)
> waxDripColor = color(255, 255, 255, 90)
> 	
> candleRoundness = 18
> candleTopY = y - 5
> 	
> centerX = x + width / 2
> 
> # Wick
> wickWidth = 3
> wickHeight = 12
> wickX = centerX - (wickWidth / 2)
> wickY = y - wickHeight + 1
> 
> graphics.color = wickColor
> graphics.fillRoundRect(wickX, wickY, wickWidth, wickHeight, 3, 3)
> 
> 	
> # Candle body
> graphics.color = candleColor
> graphics.fillOval(x, y + height - width, width, width)
> graphics.fillRoundRect(x, candleTopY, width, height + 5, candleRoundness, candleRoundness)
> 	
> # Top wax ellipse
> graphics.color = highlightColor
> graphics.fillOval(x, candleTopY - 2, width, 10)
> 	
> # Subtle vertical highlight
> graphics.color = highlightColor
> graphics.fillRoundRect(int(x + width * 0.18), y + 6, int(width * 0.18), height - 12, 8, 8)
> 	
> # Decorative candle stripes
> graphics.color = color(255, 255, 255, 55)
> stripeSpacing = 14
> stripeHeight = 6
> 	
> for stripeY in xrange(y + 8, y + height, stripeSpacing):
> graphics.fillRect(x, stripeY, width, stripeHeight)
> 	
> flameWidth = random.randint(9, 10)
> flameHeight = random.randint(14, 15)
> flameX = centerX - flameWidth / 2
> flameY = wickY - flameHeight + 3
> 	
> glowSize = 18
> graphics.color = flameGlow
> graphics.fillOval(centerX - glowSize / 2, flameY - 2, glowSize, glowSize)
> 	
> innerWidth = 5
> innerHeight = 9
> 	
> flameTilt = Math.toRadians(random.randint(18, 24))
> 	
> graphics.rotate(-flameTilt, centerX, wickY)
> 	
> graphics.color = flameOrange
> graphics.fillOval(flameX, flameY, flameWidth, flameHeight)
> 	
> graphics.color = flameYellow
> graphics.fillOval(centerX - innerWidth / 2, flameY + 3, innerWidth, innerHeight)
> 	
> graphics.color = color(255, 245, 190, 190)
> graphics.fillOval(centerX - 1, flameY + 7, 2, 4)
> 	
> graphics.rotate(flameTilt, centerX, wickY)
> 
> def paintCherry(graphics, x, y, diameter, isStemPainted = True):
> cherryDark = color(180, 0, 20)
> cherryHighlight = color(255, 220, 220, 150)
> cherryLight = color(255, 60, 80)
> stemGreen = color(40, 120, 40)
> radius = diameter / 2
> 	
> if isStemPainted:
> graphics.color = stemGreen
> graphics.setStroke(BasicStroke(2))
> x1 = x + radius
> x2 = x1 + 4
> y1 = y + 2
> y2 = y - 8
> graphics.drawLine(x1, y1, x2, y2)
> 
> # Main cherry
> graphics.paint = GradientPaint(x, y, cherryLight, x + diameter, y + diameter, cherryDark)
> graphics.fillOval(x, y, diameter, diameter)
> 	
> # Red highlight
> graphics.color = color(255, 60, 80, 100)
> graphics.fillOval(
> x + diameter / 4,
> y + diameter / 5,
> diameter / 2,
> diameter / 2)
> 	
> # Highlight
> graphics.color = cherryHighlight
> graphics.fillOval(
> x + diameter / 3,
> y + diameter / 3,
> diameter / 5,
> diameter / 5)
> 
> def paintIcingDab(graphics, x, y, size):
> icing = color(255, 240, 245)
> shadow = color(220, 200, 210, 150)
> highlight = color(255, 255, 255)
> 
> # Shadow
> graphics.setColor(shadow)
> graphics.fillOval(
> x - size / 2,
> y + size / 4,
> size,
> size / 4
> )
> 
> # Layered frosting swirl
> graphics.setColor(icing)
> 
> graphics.fillOval(
> x - size / 2,
> y - size / 4,
> size,
> size / 2
> )
> 
> graphics.fillOval(
> x - int(size * 0.35),
> y - int(size * 0.45),
> int(size * 0.7),
> int(size * 0.4)
> )
> 
> graphics.fillOval(
> x - int(size * 0.22),
> y - int(size * 0.62),
> int(size * 0.44),
> int(size * 0.3)
> )
> 
> # Peak
> graphics.fillOval(
> x - int(size * 0.08),
> y - int(size * 0.82),
> int(size * 0.16),
> int(size * 0.2)
> )
> 
> # Highlight
> graphics.setColor(highlight)
> graphics.fillOval(
> x - size / 5,
> y - size / 5,
> size / 8,
> size / 10
> )
> 
> def paintPlate(graphics, x, y, width, height, bevelColor, plateColor):
> bevelWidth = 20
> plateInnerX = x + bevelWidth
> plateInnerY = y + bevelWidth
> plateInnerWidth = width - (bevelWidth * 2)
> plateInnerHeight = height - (bevelWidth * 2)
> graphics.color = bevelColor
> graphics.fillOval(x, y, width, height)
> graphics.color = plateColor
> graphics.fillOval(plateInnerX, plateInnerY, plateInnerWidth, plateInnerHeight)
> from java.lang import Math
> def getSpacedOvalPoints(points, centerX, centerY, width, height, degreeOffset = 0):
> radiusWidth = width / 2
> radiusHeight = height / 2
> 	
> # java.lang.Math trigonometric functions requires radians
> startAngle = Math.toRadians(degreeOffset) # ...or (3.14 * degreeOffset) / 180 to eliminate the import
> 	
> # Set the end angle to 360 degrees more than the starting angle
> endAngle = startAngle + (2 * Math.PI) # This could also be written startAngle + Math.toRadians(360)
> 	
> # Abort the operation if there aren't at least two points to avoid a potential division error and other wierdness
> if points <= 1:
> yield centerX, centerY
> return
> 	
> # Iterate through each point, and yield a coordinate set for the current angle and distance from origin
> angleIncrement = (endAngle - startAngle) / float(points - 1)
> for point in xrange(points):
> angle = startAngle + (point * angleIncrement)
> yield ( # Polar-to-Cartesian conversions
> int(centerX + Math.cos(angle) * radiusWidth),
> int(centerY - Math.sin(angle) * radiusHeight))
> 
> # Cake Parameters
> cakeX = 100
> cakeY = 150
> cakeWidth = 300
> cakeHeight = 150
> cakeLight = color(240, 220, 190)
> cakeDark = color(210, 180, 150)
> cakePaint = GradientPaint(cakeX, cakeY,	cakeLight, cakeX, cakeY + cakeHeight, cakeDark)
> icingColor = color(255, 255, 255, 150)
> topCenterX = cakeX + (cakeWidth / 2.0)
> topHeight = cakeWidth / 4 # Defined outside the paintCake function because it's needed for positioning toppings
> topCenterY = cakeY + (topHeight / 2.0)
> 
> # Candle Parameters:
> candleBlue = color(0, 140, 200)
> candleWidth = 8
> candleHeight = 60	
> candleCount = 22
> candleRingWidth = cakeWidth - 120
> candleRingHeight = topHeight - 50
> halfCandleCount = candleCount / 2
> angleOffset = 13
> 
> # Cherry Parameters:
> cherryCount = 20
> cherrySize = 18
> cherryRingWidth = cakeWidth - 35
> cherryRingHeight = topHeight - 25
> halfCherryCount = cherryCount / 2
> 
> # Plate parameters
> plateWidth = int(cakeWidth * 1.5)
> plateHeight = cakeWidth / 2
> plateX = cakeX - ((plateWidth - cakeWidth) / 2)
> plateY = cakeY + cakeHeight - ((plateWidth - cakeWidth) / 4)
> bevelBlue = color(0, 120, 180)
> plateDarkBlue = color(0, 90, 140)
> 
> paintPlate(graphics, plateX, plateY, plateWidth, plateHeight, bevelBlue, plateDarkBlue)	
> paintCake(graphics, cakeX, cakeY, cakeWidth, cakeHeight, topHeight, cakePaint, icingColor)
> 
> dabDiameter = 10
> dabRadius = dabDiameter / 2
> averageDiameter = (topHeight + cakeWidth) / 2
> estimatedCircumference = averageDiameter * Math.PI
> dabCount = estimatedCircumference / dabDiameter
> 
> # Filter out the front icing dabs
> for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius)):
> if index < (dabCount / 2):
> paintIcingDab(graphics, icingX, icingY, dabDiameter)
> 
> for index, (x, y) in enumerate(getSpacedOvalPoints(cherryCount, topCenterX, topCenterY, cherryRingWidth, cherryRingHeight)):
> 	
> # Reduce the cherry size by three pixels if the index is beyond the halfway point [indicating the cherries are on the far side of the ring]
> cherryDiameter = cherrySize if index > halfCherryCount else cherrySize - 3
> 	
> # Offset the cherry by it's radius, so the cherries are centered on the coordinates returned by the getSpacedOvalPoints function
> cherryRadius = cherryDiameter / 2
> paintCherry(graphics, x - cherryRadius, y - cherryRadius, cherryDiameter)
> 
> # Filter out the back icing dabs
> for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius)):
> if index > (dabCount / 2):
> paintIcingDab(graphics, icingX, icingY, dabDiameter)
> 
> for index, (x, y) in enumerate(getSpacedOvalPoints(candleCount, topCenterX, topCenterY, candleRingWidth, candleRingHeight, angleOffset)):
> candleX = x - candleWidth / 2
> candleY = y - candleHeight
> 	
> # Shrink the candle slightly if it's in the back
> if index > halfCandleCount: 
> adjustedWidth = candleWidth
> adjustedHeight = candleHeight
> else:
> adjustedWidth = candleWidth - 2
> adjustedHeight = candleHeight - 4
> 
> paintCandle(graphics, candleX, candleY, adjustedWidth, adjustedHeight, candleBlue)
> 
> # Two little dabs on either side of the cake
> paintIcingDab(graphics, cakeX, cakeY + cakeHeight + (topHeight / 2), dabDiameter)
> paintIcingDab(graphics, cakeX + cakeWidth, cakeY + cakeHeight + (topHeight / 2), dabDiameter)
> 
> # Double the density of the dab count for the bottom icing ring
> # ...and filter out the back half of the elipse
> for index, (icingX, icingY) in enumerate(getSpacedOvalPoints(dabCount * 2, topCenterX, topCenterY + cakeHeight, cakeWidth - dabRadius, topHeight - dabRadius)):
> if index > dabCount:
> paintIcingDab(graphics, icingX, icingY, dabDiameter)
> 
> ```

---

<div class="post-metadata">

**Author:** ![Felipe\_CRM](https://avatars.discourse-cdn.com/v4/letter/f/dfb087/32.png) [@Felipe\_CRM](https://forum.inductiveautomation.com/u/Felipe_CRM)\
**Post date:** [June 16, 2026, 4:27pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/20 "2026-06-16T16:27:11Z")

</div>

I wanted to improve the layering effect a little bit so I added a few lines to sort the points by y:

```python
# Sort dabs by Y as a quick and dirty proxy for depth
ySortKey = lambda p: p[1]
sortedDabPoints = sorted(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius), key=ySortKey);
...
sortedCherryPoints = sorted(getSpacedOvalPoints(cherryCount, topCenterX, topCenterY, cherryRingWidth, cherryRingHeight), key=ySortKey);
...
sortedCandlePoints = sorted(getSpacedOvalPoints(candleCount, topCenterX, topCenterY, candleRingWidth, candleRingHeight, angleOffset), key=ySortKey);
...
sortedBottomDabPoints = sorted(getSpacedOvalPoints(dabCount * 2, topCenterX, topCenterY + cakeHeight, cakeWidth - dabRadius, topHeight - dabRadius), key=ySortKey);

```

And changed the drawing lines to use the y-sorted points instead so they get drawn in the right order:

```python
    # Filter out the front icing dabs
    for index, (icingX, icingY) in enumerate(sortedDabPoints):
        if index < (dabCount / 2):
            paintIcingDab(graphics, icingX, icingY, dabDiameter)

```

Before:

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/8/7/8780c52792737ffbb044fa3e71764e5d89456372.png)  
After:  
 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/5/4/542ba8f5bde0ac9b2fe5fc8692493ccd9e6e2586.png)

I also had to move everything to a project library because it was giving me weird scoping errors. Anyway here's the code with my changes:

> **Source Code**
>
> ```python
> #Original by justinedwards.jle
> from java.awt import GradientPaint, BasicStroke
> from java.lang import Math
> import random
> 
> #color = system.vision.color # When using 8.3 or newer
> color = system.gui.color # When using 8.1 or older
> 
> def paintCake(graphics, x, y, width, height, topHeight, cakePaint, icingColor):
> topOvalHeight = bottomOvalHeight = topHeight # width / 4 (Needs to be a ratio of the width to consistantly look right regardless of size
> sideTopOffset = topOvalHeight / 2
> bottomOvalY = y + height
> icingLengths = [
> int(height / 2.5), 
> int(height / 1.6), 
> int(height / 1.2), 
> int(height / 1.5), 
> int(height / 1.8), 
> int(height / 1.49), 
> int(height / 1.28), 
> int(height / 1.69), 
> int(height / 2.2), 
> int(height / 1.9), 
> int(height / 1.6), 
> int(height / 2.18)]
> icingWidth = width / len(icingLengths)
> startIcingX = x + icingWidth / 2
> startIcingY = y + topOvalHeight / 2
> graphics.paint = cakePaint
> graphics.fillRect(x, y + sideTopOffset, width, height)
> graphics.fillOval(x, bottomOvalY, width, bottomOvalHeight)
> graphics.color = icingColor
> graphics.stroke = BasicStroke(icingWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
> for index, icingLength in enumerate(icingLengths):
> x1 = x2 = startIcingX + (index * icingWidth)
> y1 = startIcingY
> y2 = startIcingY + icingLength
> graphics.drawLine(x1, y1, x2, y2)
> 		
> 
> graphics.paint = cakePaint
> graphics.fillOval(x, y, width, topOvalHeight)
> graphics.color = icingColor
> graphics.fillOval(x, y, width, topOvalHeight)
> 	
> def paintCandle(graphics, x, y, width, height, candleColor):
> flameYellow = color(255, 190, 0)
> flameOrange = color(255, 95, 0)
> flameGlow = color(255, 220, 80, 80)
> 	
> wickColor = color(45, 30, 20)
> shadowColor = color(0, 0, 0, 45)
> highlightColor = color(255, 255, 255, 70)
> waxDripColor = color(255, 255, 255, 90)
> 	
> candleRoundness = 18
> candleTopY = y - 5
> 	
> centerX = x + width / 2
> 
> # Wick
> wickWidth = 3
> wickHeight = 12
> wickX = centerX - (wickWidth / 2)
> wickY = y - wickHeight + 1
> 
> graphics.color = wickColor
> graphics.fillRoundRect(wickX, wickY, wickWidth, wickHeight, 3, 3)
> 
> 	
> # Candle body
> graphics.color = candleColor
> graphics.fillOval(x, y + height - width, width, width)
> graphics.fillRoundRect(x, candleTopY, width, height + 5, candleRoundness, candleRoundness)
> 	
> # Top wax ellipse
> graphics.color = highlightColor
> graphics.fillOval(x, candleTopY - 2, width, 10)
> 	
> # Subtle vertical highlight
> graphics.color = highlightColor
> graphics.fillRoundRect(int(x + width * 0.18), y + 6, int(width * 0.18), height - 12, 8, 8)
> 	
> # Decorative candle stripes
> graphics.color = color(255, 255, 255, 55)
> stripeSpacing = 14
> stripeHeight = 6
> 	
> for stripeY in xrange(y + 8, y + height, stripeSpacing):
> graphics.fillRect(x, stripeY, width, stripeHeight)
> 	
> flameWidth = random.randint(9, 10)
> flameHeight = random.randint(14, 15)
> flameX = centerX - flameWidth / 2
> flameY = wickY - flameHeight + 3
> 	
> glowSize = 18
> graphics.color = flameGlow
> graphics.fillOval(centerX - glowSize / 2, flameY - 2, glowSize, glowSize)
> 	
> innerWidth = 5
> innerHeight = 9
> 	
> flameTilt = Math.toRadians(random.randint(18, 24))
> 	
> graphics.rotate(-flameTilt, centerX, wickY)
> 	
> graphics.color = flameOrange
> graphics.fillOval(flameX, flameY, flameWidth, flameHeight)
> 	
> graphics.color = flameYellow
> graphics.fillOval(centerX - innerWidth / 2, flameY + 3, innerWidth, innerHeight)
> 	
> graphics.color = color(255, 245, 190, 190)
> graphics.fillOval(centerX - 1, flameY + 7, 2, 4)
> 	
> graphics.rotate(flameTilt, centerX, wickY)
> 
> def paintCherry(graphics, x, y, diameter, isStemPainted = True):
> cherryDark = color(180, 0, 20)
> cherryHighlight = color(255, 220, 220, 150)
> cherryLight = color(255, 60, 80)
> stemGreen = color(40, 120, 40)
> radius = diameter / 2
> 	
> if isStemPainted:
> graphics.color = stemGreen
> graphics.setStroke(BasicStroke(2))
> x1 = x + radius
> x2 = x1 + 4
> y1 = y + 2
> y2 = y - 8
> graphics.drawLine(x1, y1, x2, y2)
> 
> # Main cherry
> graphics.paint = GradientPaint(x, y, cherryLight, x + diameter, y + diameter, cherryDark)
> graphics.fillOval(x, y, diameter, diameter)
> 	
> # Red highlight
> graphics.color = color(255, 60, 80, 100)
> graphics.fillOval(
> x + diameter / 4,
> y + diameter / 5,
> diameter / 2,
> diameter / 2)
> 	
> # Highlight
> graphics.color = cherryHighlight
> graphics.fillOval(
> x + diameter / 3,
> y + diameter / 3,
> diameter / 5,
> diameter / 5)
> 
> def paintIcingDab(graphics, x, y, size):
> icing = color(255, 240, 245)
> shadow = color(220, 200, 210, 150)
> highlight = color(255, 255, 255)
> 
> # Shadow
> graphics.setColor(shadow)
> graphics.fillOval(
> x - size / 2,
> y + size / 4,
> size,
> size / 4
> )
> 
> # Layered frosting swirl
> graphics.setColor(icing)
> 
> graphics.fillOval(
> x - size / 2,
> y - size / 4,
> size,
> size / 2
> )
> 
> graphics.fillOval(
> x - int(size * 0.35),
> y - int(size * 0.45),
> int(size * 0.7),
> int(size * 0.4)
> )
> 
> graphics.fillOval(
> x - int(size * 0.22),
> y - int(size * 0.62),
> int(size * 0.44),
> int(size * 0.3)
> )
> 
> # Peak
> graphics.fillOval(
> x - int(size * 0.08),
> y - int(size * 0.82),
> int(size * 0.16),
> int(size * 0.2)
> )
> 
> # Highlight
> graphics.setColor(highlight)
> graphics.fillOval(
> x - size / 5,
> y - size / 5,
> size / 8,
> size / 10
> )
> 
> def paintPlate(graphics, x, y, width, height, bevelColor, plateColor):
> bevelWidth = 20
> plateInnerX = x + bevelWidth
> plateInnerY = y + bevelWidth
> plateInnerWidth = width - (bevelWidth * 2)
> plateInnerHeight = height - (bevelWidth * 2)
> graphics.color = bevelColor
> graphics.fillOval(x, y, width, height)
> graphics.color = plateColor
> graphics.fillOval(plateInnerX, plateInnerY, plateInnerWidth, plateInnerHeight)
> 	
> from java.lang import Math
> def getSpacedOvalPoints(points, centerX, centerY, width, height, degreeOffset = 0):
> radiusWidth = width / 2
> radiusHeight = height / 2
> 	
> # java.lang.Math trigonometric functions requires radians
> startAngle = Math.toRadians(degreeOffset) # ...or (3.14 * degreeOffset) / 180 to eliminate the import
> 	
> # Set the end angle to 360 degrees more than the starting angle
> endAngle = startAngle + (2 * Math.PI) # This could also be written startAngle + Math.toRadians(360)
> 	
> # Abort the operation if there aren't at least two points to avoid a potential division error and other wierdness
> if points <= 1:
> yield centerX, centerY
> return
> 	
> # Iterate through each point, and yield a coordinate set for the current angle and distance from origin
> angleIncrement = (endAngle - startAngle) / float(points - 1)
> for point in xrange(points):
> angle = startAngle + (point * angleIncrement)
> yield ( # Polar-to-Cartesian conversions
> int(centerX + Math.cos(angle) * radiusWidth),
> int(centerY - Math.sin(angle) * radiusHeight))
> 
> def repaint(event):
> 
> graphics = event.graphics
> 
> # Cake Parameters
> cakeX = 100
> cakeY = 150
> cakeWidth = 300
> cakeHeight = 150
> cakeLight = color(240, 220, 190)
> cakeDark = color(210, 180, 150)
> cakePaint = GradientPaint(cakeX, cakeY,	cakeLight, cakeX, cakeY + cakeHeight, cakeDark)
> icingColor = color(255, 255, 255, 150)
> topCenterX = cakeX + (cakeWidth / 2.0)
> topHeight = cakeWidth / 4 # Defined outside the paintCake function because it's needed for positioning toppings
> topCenterY = cakeY + (topHeight / 2.0)
> 	
> # Candle Parameters:
> candleBlue = color(0, 140, 200)
> candleWidth = 8
> candleHeight = 60	
> candleCount = 22
> candleRingWidth = cakeWidth - 120
> candleRingHeight = topHeight - 50
> halfCandleCount = candleCount / 2
> angleOffset = 13
> 	
> # Cherry Parameters:
> cherryCount = 20
> cherrySize = 18
> cherryRingWidth = cakeWidth - 35
> cherryRingHeight = topHeight - 25
> halfCherryCount = cherryCount / 2
> 	
> # Plate parameters
> plateWidth = int(cakeWidth * 1.5)
> plateHeight = cakeWidth / 2
> plateX = cakeX - ((plateWidth - cakeWidth) / 2)
> plateY = cakeY + cakeHeight - ((plateWidth - cakeWidth) / 4)
> bevelBlue = color(0, 120, 180)
> plateDarkBlue = color(0, 90, 140)
> 	
> paintPlate(graphics, plateX, plateY, plateWidth, plateHeight, bevelBlue, plateDarkBlue)	
> paintCake(graphics, cakeX, cakeY, cakeWidth, cakeHeight, topHeight, cakePaint, icingColor)
> 	
> 	
> dabDiameter = 10
> dabRadius = dabDiameter / 2
> averageDiameter = (topHeight + cakeWidth) / 2
> estimatedCircumference = averageDiameter * Math.PI
> dabCount = estimatedCircumference / dabDiameter
> 	
> # Sort dabs by Y as a quick and dirty proxy for depth
> ySortKey = lambda p: p[1]
> sortedDabPoints = sorted(getSpacedOvalPoints(dabCount, topCenterX, topCenterY, cakeWidth - dabRadius, topHeight - dabRadius), key=ySortKey);
> 	
> # Filter out the front icing dabs
> for index, (icingX, icingY) in enumerate(sortedDabPoints):
> if index < (dabCount / 2):
> paintIcingDab(graphics, icingX, icingY, dabDiameter)
> 	
> sortedCherryPoints = sorted(getSpacedOvalPoints(cherryCount, topCenterX, topCenterY, cherryRingWidth, cherryRingHeight), key=ySortKey);
> 	
> for index, (x, y) in enumerate(sortedCherryPoints):
> 		
> # Reduce the cherry size by three pixels if the index is beyond the halfway point [indicating the cherries are on the far side of the ring]
> cherryDiameter = cherrySize if index > halfCherryCount else cherrySize - 3
> 		
> # Offset the cherry by it's radius, so the cherries are centered on the coordinates returned by the getSpacedOvalPoints function
> cherryRadius = cherryDiameter / 2
> paintCherry(graphics, x - cherryRadius, y - cherryRadius, cherryDiameter)
> 	
> # Filter out the back icing dabs
> for index, (icingX, icingY) in enumerate(sortedDabPoints):
> if index > (dabCount / 2):
> paintIcingDab(graphics, icingX, icingY, dabDiameter)
> 	
> sortedCandlePoints = sorted(getSpacedOvalPoints(candleCount, topCenterX, topCenterY, candleRingWidth, candleRingHeight, angleOffset), key=ySortKey);
> 	
> for index, (x, y) in enumerate(sortedCandlePoints):
> candleX = x - candleWidth / 2
> candleY = y - candleHeight
> 		
> # Shrink the candle slightly if it's in the back
> if index > halfCandleCount: 
> adjustedWidth = candleWidth
> adjustedHeight = candleHeight
> else:
> adjustedWidth = candleWidth - 2
> adjustedHeight = candleHeight - 4
> 	
> paintCandle(graphics, candleX, candleY, adjustedWidth, adjustedHeight, candleBlue)
> 	
> # Two little dabs on either side of the cake
> paintIcingDab(graphics, cakeX, cakeY + cakeHeight + (topHeight / 2), dabDiameter)
> paintIcingDab(graphics, cakeX + cakeWidth, cakeY + cakeHeight + (topHeight / 2), dabDiameter)
> 	
> sortedBottomDabPoints = sorted(getSpacedOvalPoints(dabCount * 2, topCenterX, topCenterY + cakeHeight, cakeWidth - dabRadius, topHeight - dabRadius), key=ySortKey);
> 	
> # Double the density of the dab count for the bottom icing ring
> # ...and filter out the back half of the elipse
> for index, (icingX, icingY) in enumerate(sortedBottomDabPoints):
> if index > dabCount:
> paintIcingDab(graphics, icingX, icingY, dabDiameter)# Cake Parameters
> 
> ```

EDIT: This way stores the points in lists instead of nice generators so it's not as memory-efficient. There's probably a way to make the oval point generator return the points in y order directly to keep that benefit.

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [June 16, 2026, 4:34pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/21 "2026-06-16T16:34:22Z")

</div>

> [@Felipe\_CRM](#):
>
> it was giving me weird scoping errors

You must have legacy scoping selected in your script editor. Switching it to standard scoping will fix that issue:

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/9/b/9bc7ac25756ee527fe72c40c3012163a00e3de7e.png)

...but all of this belongs in the script library nevertheless. That's why I abstracted everything out into functions in the first place and was sure to include the graphics variable in all of the functions. That makes the functions seamless to cut and paste into the library, so they can be called from any canvas in the project.

---

<div class="post-metadata">

**Author:** ![Felipe\_CRM](https://avatars.discourse-cdn.com/v4/letter/f/dfb087/32.png) [@Felipe\_CRM](https://forum.inductiveautomation.com/u/Felipe_CRM)\
**Post date:** [June 16, 2026, 4:36pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/22 "2026-06-16T16:36:41Z")

</div>

> [@justinedwards.jle](#):
>
> legacy scoping checked on your script editor

Oh, you're right, I never realized that was an option because I usually put stuff in the script library to begin with 😅. I thought maybe it something they fixed in 8.3 (we're still on 8.1)

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [June 16, 2026, 4:46pm UTC](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437/23 "2026-06-16T16:46:13Z")

</div>

> [@Felipe\_CRM](#):
>
> I thought maybe it something they fixed in 8.3

My dev server is 8.3, and the dropdown is still there, but it's rarely a pain point because who would build functions like this in an event handler? Especially with animation scripts that require imports and fire many times a second, there's no way I would put those anywhere but the script library. For simple one off functions that for whatever reason aren't going to make it into the script library, I typically use custom methods.

[Next page](https://forum.inductiveautomation.com/t/paintable-canvas-hacks/85437.md?page=2)
