Paintable Canvas Hacks

You can use the technique I outlined here to filter out problematic instances:

In this case, the code would simply be:

	if index != cherryCount - 1:
		paintCherry(graphics, x - cherryRadius, y - cherryRadius, cherryDiameter)

Result:

I'll also add that keeping the points in a clockwise order makes it possible to use them directly in creating Polygons.

That's a good point. In case you don't mind losing out on Polygon ability, here's a generator modified to return (mostly) y-sorted points by "zig-zagging" around the circle. Using a large enough degree offset will break this but it works decently otherwise.

def getYSortedSpacedOvalPoints(points, centerX, centerY, width, height, degreeOffset = 0):
    #same as getSpacedOvalPoints but returns points from top to bottom.
    #degreeOffset technically breaks the ordering but for small offsets should not be noticeable
    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)
    for point in xrange(points):
        angle = startAngle + (((point+1)//2) * angleIncrement) * (-1 if (point % 2 == 1) else 1)
        yield ( # Polar-to-Cartesian conversions
            int(centerX + Math.sin(angle) * radiusWidth),
            int(centerY - Math.cos(angle) * radiusHeight))

Using this should reduce the number of problem points that need to be skipped without having to store a sorted list in memory.

EDIT: Noticed the spacing now looks a little off on the frontmost dabs :frowning: Spacing calculation probably needs adjustment

EDIT 2: I found the issue(s) after thinking for 20 minutes I had some weird floating point error accumulation. First, angle increment now divides by points instead of points-1.
2nd, the dab count was using float division which was giving a non-integer number of dabs, which threw off the increment calculation and maybe was the reason points-1 was needed as an adjustment earlier.

#dabCount = estimatedCircumference / dabDiameter
#changed to
dabCount = estimatedCircumference // dabDiameter

Before:

After:

Functions for Wrapping Text Around a Cylinder or Curving Text Around an Ellipse

Before we can perform either of these functions, we will need to be able to calculate the circumference of the ellipse, and that's not so easy to do. In fact, there isn't a precise equation for calculating the perimeter of any oval other than a circle. However, there are quite a few ways to accurately estimate it. Here is a helper function for quickly getting an ellipse's perimeter distance:

from java.lang import Math
def getOvalCircumference(width, height):
	horizontalRadius = width / 2.0 # Major Axis
	verticleRadius = height / 2.0 # Minor Axis
	
	# Calculate a close approximation of the ellipse's circumference
	# https://www.mathsisfun.com/geometry/ellipse-perimeter.html ~ "Approximation 2"
	return Math.PI * (3 * (horizontalRadius + verticleRadius) - Math.sqrt((3 * horizontalRadius + verticleRadius) * (horizontalRadius + 3 * verticleRadius)))

Armed with that, it is now possible to curve text around an oval using this function:

from java.lang import Math
def drawStringAroundOval(graphics, text, x, y, width, height, degreeOffset=0, isInverted=False):
	centerX = x + width / 2.0
	centerY = y + height / 2.0
	horizontalRadius = width / 2.0 # Major Axis
	verticalRadius = height / 2.0 # Minor Axis
	circumference = getOvalCircumference(width, height)
	
	fontMetrics = graphics.fontMetrics
	
	# Set the unit circle starting angle around the ellips for the string
	angle = Math.toRadians(degreeOffset)
	
	 # If inverted, the top of the characters will face the origin instead of the bottom
	direction = -1 if isInverted else 1
	for character in text:
		# Get the current transform for the graphics object,
		# ...so it can be reset for the next character position calculation
		originalTransform = graphics.getTransform()
		
		# Measure how much horizontal space this character takes in the current font,
		# ...and convert that character width into an angular amount around the cylinder.
		characterWidth = fontMetrics.charWidth(character)		
		widthOffset = (characterWidth / circumference) * 2 * Math.PI * direction		
		angle -= widthOffset / 2.0	# Move halfway into this character's angular space before drawing it to center the character on its respective angle
		
		# Calculate the x and y coordinates for the character on the ellipse, and translate origin to that position,
		# ...so all that has be calculated to draw in the right place are the centering offsets
		characterX = centerX + Math.cos(angle) * horizontalRadius
		characterY = centerY - Math.sin(angle) * verticalRadius
		graphics.translate(characterX, characterY)
		
		# Calculate the tangent direction of the ellipse at this angle, and rotate the character accordingly
		tangentX = -horizontalRadius * Math.sin(angle)
		tangentY = -verticalRadius * Math.cos(angle)
		rotationAngle = Math.atan2(tangentY, tangentX) if isInverted else Math.atan2(tangentY, tangentX) + Math.PI 
		graphics.rotate(rotationAngle)
		
		# Adjust the string position to center it vertically on its ellipse and horizontally on its angle
		centeringOffsetX = -characterWidth / 2
		centeringOffsetY = fontMetrics.ascent / 2
		graphics.drawString(character, centeringOffsetX, centeringOffsetY)
		
		# Restore the graphics transform for the next character.
		graphics.setTransform(originalTransform)
		
		# Add in the second half of the character's width,
		# ...which places its value at the start of the next character's space
		angle -= widthOffset / 2.0

In this script, x, y, width and height are obviously the position and dimension of the oval.
Supplying a degreeOffset will move the string around the ellipse in a counter clockwise direction if a positive value is supplied or a clockwise direction if a negative value is supplied.
I added an optional isInverted boolean flag because I could imagine times when it would be desirable for the top of the text to face inward, but if set to false or ignored, the bottom of the text will face inward.

I imagine that usually, the length of the string will not be long enough to circumvent the given oval, and in those instances, it would be desirable to center the text either on the top or the bottom of whatever is being drawn around. To accomplish this, use fontMetrics to determine the width of the string and use it to calculate what percentage of the oval's circumference is being consumed by the text. The angular offset will be that percentage of 360 degrees. My function always starts at 0 degrees as it appears on the unit circle, and intuitively, a positive offset will always push the text counter clockwise, so whether or not the text is inverted matters, since inverted text will be typed in the opposite direction.

Here is an example of how how to handle either scenario to position the text, so it is centered at the top of the ellipse:

testString = 'Coffee is that savory elixir that transforms complex logic into functional code and abstract ideas into deployed reality'
####
# Calculate position and dimensions here
####
isInverted = False # or True
circumference = event.source.getOvalCircumference(stringOvalWidth, stringOvalHeight)
stringWidth = fontMetrics.stringWidth(testString)
ovalPercentage = float(stringWidth) / circumference
angleOffset = (360 * ovalPercentage) / 2
stringOvalAngleOffset = 90 - angleOffset if isInverted else 90 + angleOffset # Always centered at the top
event.source.drawStringAroundOval(graphics, testString, x, y, width, height, stringOvalAngleOffset, isInverted)

Of course, from here the next natural progression is to wrap text around a cylinder. Here is a library script I developed that does this:

from java.lang import Math
from java.awt import AlphaComposite    # Used to fade the letters slightly at the edges
def drawStringAroundCylinder(graphics, text, x, y, width, height):
    fontMetrics = graphics.fontMetrics
    
    # Make it impossible for any characters to overlap the edges of the cup
    originalClip = graphics.getClip()
    graphics.setClip(x, y, width, (2 * height))
    
    # This is used to slightly widen the major axis of the ellipse,
    # ...so the text won't curve up as much at the edges
    # ...making it easier to squish them around the edges of the cylinder
    flatteningFactor = 1.15
    flattenedWidth = flatteningFactor * width
    
    # The center of the ellipse
    centerX = x + width / 2.0
    centerY = y + height / 2.0
    
    # The radii of the magor and minor axes
    horizontalRadius = flattenedWidth / 2.0 # Modified to be wider than the actual cup
    verticalRadius = height / 2.0
    
    # Get the estinated circumference of the slightly widened oval
    circumference = getOvalCircumference(flattenedWidth, height)
    
    # Starting at the middle of the given string and working outward,
    # ...get the letters that will actually fit or partially fit within the bounds of the cup
    index = 0
    midIndex = len(text) / 2
    finalString = text[midIndex]
    while fontMetrics.stringWidth(finalString) <= width and index <= midIndex:
        index += 1
        startIndex = max(0, midIndex - index)
        endIndex = min(len(text), midIndex + index)
        finalString = text[startIndex:endIndex] # Do this last to ensure it adds two overhanging letters before the loop breaks
    
    # Get the width of the clipped string,
    # ...and caculate the necessary angular offset to center the text on the cylinder
    stringWidth = fontMetrics.stringWidth(finalString) / flatteningFactor
    ovalPercentage = float(stringWidth) / circumference
    angleOffset = 360.0 * ovalPercentage / 2.0
    startAngle = 270.0 - angleOffset
    
    # Start the text on the offset starting angle, and individually draw each character in the circle
    angle = Math.toRadians(startAngle)
    for character in finalString:
        
        # Measure how much horizontal space this character takes in the current font,
        # ...and convert that character width into an angular amount around the cylinder.
        characterWidth = fontMetrics.charWidth(character) / flatteningFactor
        widthOffset = characterWidth / circumference * 2.0 * Math.PI
        angle += widthOffset / 2.0
        
        # Calculate the x and y coordinates for the character on the ellipse
        characterX = centerX + Math.cos(angle) * horizontalRadius
        characterY = centerY - Math.sin(angle) * verticalRadius
        
        # Take the absolute value of the distance from origin
        # ...to determine how close the character is to the left or right edge of the ellipse.
        edgeFactor = abs(characterX - centerX) / horizontalRadius# The value of edge is 0 at the center and approaches 1 at either side.
        
        # Use the proxity from the edge calculate an amount squish the letters horizontally as they approach the edge of the cylinder
        # ...to simulate the letters angling away around the curvature of the cup
        squishFactor = Math.sqrt(max(0.0, 1.0 - edgeFactor * edgeFactor))

        # Save the current graphics state so this character's transparency,
        # ...translation, and scaling do not affect the next character.
        originalTransform = graphics.getTransform()
        originalComposite = graphics.composite        
        
        # Make the characters fade from view as they get closer to the cylinder edge.
        graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, squishFactor)
        
        # Move the drawing origin to the character's ellipse position,
        # ...and apply the squish factor to horizontally squeeze characters that are near the edges.
        graphics.translate(characterX, characterY)
        graphics.scale(squishFactor, 1.0)
        
        # Draw the character centered on the transformed origin.
        centeringOffsetX = -characterWidth / 2
        centeringOffsetY = fontMetrics.ascent / 2
        graphics.drawString(character, centeringOffsetX, centeringOffsetY)
        
        # Restore original composite and transform,
        # ...and increment the angle in preparation for the next cycle
        graphics.composite = originalComposite        
        graphics.setTransform(originalTransform)    
        angle += widthOffset / 2.0
        
    # Restore the drawing and painting ability outside the bounds of the cup
    graphics.setClip(originalClip)

This is similar to curving text around an oval except I've found that not angling the characters produces a more believable effect, and furthermore, some hocus pocus is needed near edges of the cylinder to make the text appear to curve away. Lastly, an ellipse tends to start curving up sharply near its edge, and that looks unnatural when the goal is to curve around to the back instead of up, so I've found that it's better to make the ellipse of the cylinder slightly wider to flatten out the curve a bit.

To demonstrate the effectiveness of this technique, I'll add a fontSize property to the paintable canvas and bind it to the floatValue of a spinner component that I've set up to increment at a value of 0.5
image

I'll package the preceding functions into custom methods that I'll call from the repaint event:

Note: The last two custom methods are used to generate the coffee cup that I will be wrapping with text. I'll provide all of the scripts at the bottom of the post in case anybody has a use for them.

Watch how the text appears to wrap around the edges as the font size increases and the text begins to overflow:
Coffee Cup

Here is the repaint event along with each custom method I developed for this tutorial. Simply click on the arrows to reveal and copy the code:

Paintable canvas repaint event handler:

repaint
graphics = event.graphics
color = system.vision.color # Version 8.3 or newer
#color = system.gui.color # Version 8.1 or older
 
# The coffee cup
cupX = cupY = 100
cupWidth = 140
cupHeight = 180
cupTopOvalHeight = 40
event.source.paintCoffeeCup(graphics, cupX, cupY, cupWidth, cupHeight)

# The large string to be drawn in an oval well outside the bounds of the cup
originalFont = graphics.font
graphics.font = originalFont.deriveFont(16.0).deriveFont(originalFont.BOLD + originalFont.ITALIC)
fontMetrics = graphics.fontMetrics
testString = 'Coffee is that savory elixir that transforms complex logic into functional code and abstract ideas into deployed reality'
stringOvalMargin = 80 # How far away from the outside of the cup, should this oval of text be
stringOvalX = cupX - stringOvalMargin
stringOvalY = cupY - stringOvalMargin
stringOvalWidth = cupWidth + (2 * stringOvalMargin)
stringOvalHeight = cupHeight + (2 * stringOvalMargin)

isInverted = True
circumference = event.source.getOvalCircumference(stringOvalWidth, stringOvalHeight)
stringWidth = fontMetrics.stringWidth(testString)
ovalPercentage = float(stringWidth) / circumference
angleOffset = (360 * ovalPercentage) / 2
stringOvalAngleOffset = 90 - angleOffset if isInverted else 90 + angleOffset
graphics.color = color('black')
event.source.drawStringAroundOval(graphics, testString, stringOvalX, stringOvalY, stringOvalWidth, stringOvalHeight, stringOvalAngleOffset, isInverted)

# The text that is wrapped around the outside of the coffee cup
graphics.font = originalFont.deriveFont(event.source.fontSize).deriveFont(originalFont.BOLD)
nextTestStrings = [unicode('I LOVE ♥'), 'Good Coffee'] # Two lines to print
graphics.color = color(0, 0, 0, 155)
lineSpacing = int(event.source.fontSize * 1.5)
for index, word in enumerate(nextTestStrings):
    event.source.drawStringAroundCylinder(graphics, word, cupX, cupY + cupTopOvalHeight + (index * lineSpacing), cupWidth, cupTopOvalHeight)

Custom Methods on the paintable canvas:

drawStringAroundCylinder
#def drawStringAroundCylinder(self, graphics, text, x, y, width, height):
	from java.lang import Math
	from java.awt import AlphaComposite	# Used to fade the letters slightly at the edges
	fontMetrics = graphics.fontMetrics
	
	# Make it impossible for any characters to overlap the edges of the cup
	originalClip = graphics.getClip()
	graphics.setClip(x, y, width, (2 * height))
	
	# This is used to slightly widen the major axis of the ellipse,
	# ...so the text won't curve up as much at the edges
	# ...making it easier to squish them around the edges of the cylinder
	flatteningFactor = 1.15
	flattenedWidth = flatteningFactor * width
	
	# The center of the ellipse
	centerX = x + width / 2.0
	centerY = y + height / 2.0
	
	# The radii of the magor and minor axes
	horizontalRadius = flattenedWidth / 2.0 # Modified to be wider than the actual cup
	verticalRadius = height / 2.0
	
	# Get the estinated circumference of the slightly widened oval
	circumference = self.getOvalCircumference(flattenedWidth, height)
	
	# Starting at the middle of the given string and working outward,
	# ...get the letters that will actually fit or partially fit within the bounds of the cup
	index = 0
	midIndex = len(text) / 2
	finalString = text[midIndex]
	while fontMetrics.stringWidth(finalString) <= width and index <= midIndex:
		index += 1
		startIndex = max(0, midIndex - index)
		endIndex = min(len(text), midIndex + index)
		finalString = text[startIndex:endIndex] # Do this last to ensure it adds two overhanging letters before the loop breaks
	
	# Get the width of the clipped string,
	# ...and caculate the necessary angular offset to center the text on the cylinder
	stringWidth = fontMetrics.stringWidth(finalString) / flatteningFactor
	ovalPercentage = float(stringWidth) / circumference
	angleOffset = 360.0 * ovalPercentage / 2.0
	startAngle = 270.0 - angleOffset
	
	# Start the text on the offset starting angle, and individually draw each character in the circle
	angle = Math.toRadians(startAngle)
	for character in finalString:
		
		# Measure how much horizontal space this character takes in the current font,
		# ...and convert that character width into an angular amount around the cylinder.
		characterWidth = fontMetrics.charWidth(character) / flatteningFactor
		widthOffset = characterWidth / circumference * 2.0 * Math.PI
		angle += widthOffset / 2.0
		
		# Calculate the x and y coordinates for the character on the ellipse
		characterX = centerX + Math.cos(angle) * horizontalRadius
		characterY = centerY - Math.sin(angle) * verticalRadius
		
		# Take the absolute value of the distance from origin
		# ...to determine how close the character is to the left or right edge of the ellipse.
		edgeFactor = abs(characterX - centerX) / horizontalRadius# The value of edge is 0 at the center and approaches 1 at either side.
		
		# Use the proxity from the edge calculate an amount squish the letters horizontally as they approach the edge of the cylinder
		# ...to simulate the letters angling away around the curvature of the cup
		squishFactor = Math.sqrt(max(0.0, 1.0 - edgeFactor * edgeFactor))

		# Save the current graphics state so this character's transparency,
		# ...translation, and scaling do not affect the next character.
		originalTransform = graphics.getTransform()
		originalComposite = graphics.composite		
		
		# Make the characters fade from view as they get closer to the cylinder edge.
		graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, squishFactor)
		
		# Move the drawing origin to the character's ellipse position,
		# ...and apply the squish factor to horizontally squeeze characters that are near the edges.
		graphics.translate(characterX, characterY)
		graphics.scale(squishFactor, 1.0)
		
		# Draw the character centered on the transformed origin.
		centeringOffsetX = -characterWidth / 2
		centeringOffsetY = fontMetrics.ascent / 2
		graphics.drawString(character, centeringOffsetX, centeringOffsetY)
		
		# Restore original composite and transform,
		# ...and increment the angle in preparation for the next cycle
		graphics.composite = originalComposite		
		graphics.setTransform(originalTransform)	
		angle += widthOffset / 2.0
		
	# Restore the drawing and painting ability outside the bounds of the cup
	graphics.setClip(originalClip)
drawStringAroundOval
#def drawStringAroundOval(self, graphics, text, x, y, width, height, degreeOffset=0, isInverted=False):
	from java.lang import Math
	centerX = x + width / 2.0
	centerY = y + height / 2.0
	horizontalRadius = width / 2.0 # Major Axis
	verticalRadius = height / 2.0 # Minor Axis
	circumference = self.getOvalCircumference(width, height)
	
	fontMetrics = graphics.fontMetrics
	
	# Set the unit circle starting angle around the ellips for the string
	angle = Math.toRadians(degreeOffset)
	
	 # If inverted, the top of the characters will face the origin instead of the bottom
	direction = -1 if isInverted else 1
	for character in text:
		# Get the current transform for the graphics object,
		# ...so it can be reset for the next character position calculation
		originalTransform = graphics.getTransform()
		
		# Measure how much horizontal space this character takes in the current font,
		# ...and convert that character width into an angular amount around the cylinder.
		characterWidth = fontMetrics.charWidth(character)		
		widthOffset = (characterWidth / circumference) * 2 * Math.PI * direction		
		angle -= widthOffset / 2.0	# Move halfway into this character's angular space before drawing it to center the character on its respective angle
		
		# Calculate the x and y coordinates for the character on the ellipse, and translate origin to that position,
		# ...so all that has be calculated to draw in the right place are the centering offsets
		characterX = centerX + Math.cos(angle) * horizontalRadius
		characterY = centerY - Math.sin(angle) * verticalRadius
		graphics.translate(characterX, characterY)
		
		# Calculate the tangent direction of the ellipse at this angle, and rotate the character accordingly
		tangentX = -horizontalRadius * Math.sin(angle)
		tangentY = -verticalRadius * Math.cos(angle)
		rotationAngle = Math.atan2(tangentY, tangentX) if isInverted else Math.atan2(tangentY, tangentX) + Math.PI 
		graphics.rotate(rotationAngle)
		
		# Adjust the string position to center it vertically on its ellipse and horizontally on its angle
		centeringOffsetX = -characterWidth / 2
		centeringOffsetY = fontMetrics.ascent / 2
		graphics.drawString(character, centeringOffsetX, centeringOffsetY)
		
		# Restore the graphics transform for the next character.
		graphics.setTransform(originalTransform)
		
		# Add in the second half of the character's width,
		# ...which places its value at the start of the next character's space
		angle -= widthOffset / 2.0
getOvalCircumference
#def getOvalCircumference(self, width, height):
	from java.lang import Math
	horizontalRadius = width / 2.0 # Major Axis
	verticleRadius = height / 2.0 # Minor Axis
	
	# Calculate a close approximation of the ellipse's circumference
	# https://www.mathsisfun.com/geometry/ellipse-perimeter.html ~ "Approximation 2"
	return Math.PI * (3 * (horizontalRadius + verticleRadius) - Math.sqrt((3 * horizontalRadius + verticleRadius) * (horizontalRadius + 3 * verticleRadius)))
getSpacedOvalPoints
#def getSpacedOvalPoints(self, pointCount, centerX, centerY, width, height, degreeOffset=0):
	from java.lang import Math
	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 pointCount <= 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(pointCount - 1)
	for point in xrange(pointCount):
		angle = startAngle + (point * angleIncrement)
		yield ( # Polar-to-Cartesian conversions
			int(centerX + Math.cos(angle) * radiusWidth),
			int(centerY - Math.sin(angle) * radiusHeight))
paintCoffeeCup
#def paintCoffeeCup(self, graphics, x, y, width, height):
	from java.awt import BasicStroke
	color = system.gui.color
	
	rimWidth = 4
	cupTopHeight = 40
	cupX = cupY = 0
	cupWidth = 140
	cupHeight = 180
	cupID = cupWidth - (2 * rimWidth)
	cirumference = int(3.14 * cupWidth)
	
	# Allow the cup to be resized, but for simplicity,
	# ...just scale the original development size to the given size
	originalTransform = graphics.getTransform()		# ...so the scaling and origin can be restored after the coffee cup is painted
	graphics.translate(x, y)						# Translate the disired x and y to origin, so the cup doesn't move when scaled
	graphics.scale(float(width) / cupWidth, float(height) / cupHeight)

	lightRGB = 240
	shadowRGB = 180
	grey = color('lightgrey')
	darkGrey = color(120,120,120)
	coffee = color(75,45,25)
	shadow = color(shadowRGB, shadowRGB, shadowRGB)
	highlight = color(lightRGB, lightRGB ,lightRGB)
	
	# Handle
	handleThickness = 18
	handleX = cupX + cupWidth - 10
	handleY = cupY + 45
	handleWidth = 55
	handleHeight = 85
	for strokeWidth in xrange(handleThickness, 0, -1):
		shadowPercentage = float(strokeWidth) / handleThickness
		adjustedRGB = lightRGB - int((lightRGB - shadowRGB) * shadowPercentage)
		alpha = 255 if strokeWidth == handleThickness else 10
		graphics.color = color(adjustedRGB, adjustedRGB, adjustedRGB, alpha)
		graphics.stroke = BasicStroke(strokeWidth)
		graphics.drawOval(handleX - 20, handleY, handleWidth, handleHeight)
	
	
	# Added to eliminate aliasing at large size,
	# ...and to smooth out the bottom after cup highlighting
	graphics.color = grey
	graphics.fillRect(cupX, cupY + (cupTopHeight / 2), cupWidth, cupHeight - cupTopHeight)
	graphics.fillOval(cupX, cupY + cupHeight - cupTopHeight + 1, cupWidth, cupTopHeight)
	
	# Create the body of the cup with vertical lines in a light to shadow arc
	centerX = cupX + (cupWidth / 2)
	centerY = cupY + (cupTopHeight / 2)
	for index, (x, y) in enumerate(self.getSpacedOvalPoints(cirumference, centerX, centerY, cupWidth, cupTopHeight)):
		if index > cirumference / 2:
			shadowPercentage = float(index - cirumference / 2) / (cirumference / 2)
			adjustedRGB = lightRGB - int((lightRGB - shadowRGB) * shadowPercentage)
			graphics.color = color(adjustedRGB, adjustedRGB, adjustedRGB)
			graphics.drawLine(x, y, x, y + cupHeight - cupTopHeight)
	
	# Inside visible part of the cup
	graphics.fillOval(cupX + (rimWidth / 2), cupY, cupWidth - rimWidth, cupTopHeight)
	
	# Cup Rim
	graphics.stroke = BasicStroke(rimWidth)
	graphics.color = grey
	graphics.drawOval(cupX + (rimWidth / 2), cupY, cupWidth - rimWidth, cupTopHeight)
	
	# Coffee Surface
	graphics.color = coffee
	liquidInset = 6
	levelDrop = -8
	graphics.fillOval(cupX + liquidInset, cupY - levelDrop, cupWidth - (2 * liquidInset), cupTopHeight - (liquidInset + rimWidth))
	
	# Coffee spot
	spotX = 30
	spotY = 12
	spotWidth = 30
	spotHeight = 8
	graphics.color = color(130,90,60)
	graphics.fillOval(cupX + spotX, cupY + spotY, spotWidth, spotHeight)
	
	# Steam - S-shaped curls
	steamTopY = cupY - 18
	curlSpacing = curlOneX = cupID / 4
	curlTwoX = curlOneX + curlSpacing
	curlThreeX = curlTwoX + curlSpacing
	steamHeight = 28
	steamWidth = 22
	steamThickness = 6
	graphics.color = color(130,90,60, 50).brighter() #highlight
	graphics.stroke = BasicStroke(steamThickness)
	for steamX in [cupX + curlOneX, cupX + curlTwoX, cupX + curlThreeX]:
		# top curve
		graphics.drawArc(steamX, steamTopY - 22, steamWidth, steamHeight, 270, 140)
		
		# bottom curve
		graphics.drawArc(steamX, steamTopY + steamThickness, steamWidth, steamHeight, 114, 140)
	
	# Restore original graphics positioning and painting parameters
	graphics.setTransform(originalTransform)

Wrapping complex geometric shapes and text around a sphere

Hello World

In my last tutorial, we wrapped shapes and text around a cylinder, so a spherical shape seems like the logical next progression. The easiest way to generate a sphere in the Paintable Canvas is with a gradient paint, so for this example, let's create a simple library function that uses a radial gradient paint to generate an Earth like world without any land:

from java.awt import BasicStroke, RadialGradientPaint
def paintGlobe(graphics, x, y, diameter):
	
	# Paint the atmosphere before clipping the graphics
	graphics.color = color(255, 255, 255, 50)
	graphics.stroke = BasicStroke(2)
	graphics.drawOval(x, y, diameter, diameter)

	# Base ocean sphere
	base = RadialGradientPaint(
		x + diameter * 0.28, y + diameter * 0.38, diameter * 0.82,
		[0.0, 0.45, 1.0],
		[color(120, 200, 255), color(20, 85, 180), color(1, 6, 25)])
	graphics.paint = base
	graphics.fillOval(x, y, diameter, diameter)

	# Soft reflected highlight
	highlight = RadialGradientPaint(
		x + diameter * 0.18, y + diameter * 0.46, diameter * 0.22,
		[0.0, 0.35, 1.0],
		[color(255, 255, 255, 230),	color(145, 210, 255, 90), color(145, 210, 255, 0)])
	graphics.setPaint(highlight)
	graphics.fillOval(x, y, diameter, diameter)

The coastlines of the world are a bit too complex for me to try to create using polygons, so instead, I'll use an image of a basic flat map and develop a function to map the edges in some way.


Using this black and white version, I'll simply iterate through it vertically and map the points where the colors change. Using the Script Console, I can load the image this way:

from java.io import File
from javax.imageio import ImageIO

path = "C:/TestFiles/ImageFiles"
filename = "bwWorldMap.png"

imageFile = File(path, filename)
bwMap = ImageIO.read(imageFile)

From there, I've developed a library script for mapping black and white images into vertical lines that contain all the y1 and y2 coordinates for any black segments within each line. I don't have to store any x coordinates because I store the map as a single ordered string. Therefore, each consecutive line inherently represents the next x coordinate. Of course, if there are lines without any segments, I have to use a placeholder to maintain the order, but that still uses far less memory than storing full coordinate sets.

I do recommend shrinking the image to the smallest possible size before mapping. Remember that graphics.scale can expand small objects to any size, so there's no advantage to the extra coordinate sets that have to be saved if the image is scanned at a larger size. Furthermore, most shapes don't require a high level of precision during mapping, so I include an optional argument in the function that I call resolution. It allows me to count by increments greater than one when iterating through the pixels, and when I map this image, I'll be using a resolution of 3, meaning I'm only checking every third line in the image.

I'll call it from the Script Console like this:

animations.rotatingGlobe.getVerticalLineMap(bwMap, 3)

...and I'll store it as a string within the project, so I won't have to regenerate it every time I need to use it. The string will separate lines using colons and delimit the segments within each line using semicolons. The y endpoints for each segment will be separated by a comma.

# Creates a map of all black vertical line segments within a black and white image
def getVerticalLineMap(image, resolution = 1): # The lower the integer, the higher the resolution
	width = image.width
	height = image.height
	lines = [] # A line is a collection of all verticle segments at a given x coordinate
	
	# The resolution variable is really the number to count by when checking pixels	
	for x in xrange(0, width, resolution):
		segments = []
		isSegment = False
		startY = 0 # Obviously
		for y in xrange(height):
			
			# Evaluate whether the current pixel is both opaque and black
			rgba = image.getRGB(x, y)
			alpha = (rgba >> 24) & 0xff
			color = rgba & 0x00ffffff
			isBlack = alpha > 40  and color < 40
			
			# Start segment mapping
			if isBlack and not isSegment:
				startY = y
				isSegment = True
			
			# End segment mapping
			elif not isBlack and isSegment:
				# y1, y2 coordinates are seperated by a comma
				segments.append("%d,%d" % (startY, y - 1))
				isSegment = False
		
		# End the segment if the bottom of the line has been reached, and a segment map is still in progress
		if isSegment:
			segments.append("%d,%d" % (startY, height - 1))
		
		# Segments within the same vertical plane are seperated by a semicolon
		# When no segments are found, use a placeholder to mark where the line is
		# ...because the segment index IS the x coordinate. This saves from having to store that information in the map
		lines.append(";".join(segments) if segments else "_")
	
	# Each vertical plane is separated by a colon
	return ":".join(lines)

With that, we can finally start developing a function to paint the continents. If I want to wrap the image around the planet, I'll need to calculate where each line should be placed in a 360 degree circle. I'll treat each line in the image as a line of longitude. Therefore, the line count will be my circumference. Using that, I can evaluate what percentage of the circumference each line represents and multiply that by 360 to get each line's base angular position around the globe. If I want to rotate the image, I can take an arbitrary number and treat it as if it were a longitude line. Then, I can calculate an angle in the same way and either add or subtract it from the base angle to rotate the image clockwise or counterclockwise around the sphere.

# 360 degrees (2PI radians) * percentage of the circumference represented by the line number or given offset
baseAngle = ((float(line) / circumference) * PI * 2.0)
rotationAngle = ((longitudeOffset / float(circumference)) * PI * 2.0)

# Note: The direction of rotation can be changed here by adding instead of subtracting
position = baseAngle - rotationAngle


Looking at the unit circle, we can see that if we treat the center of the sphere as angle 0, the cosine of all negative angles will represent the back side of the sphere. If we take the sine of the resultant angle, we can also see that the value will approach zero the closer a point gets to the edge of the sphere. Consequently, we can use cosine to filter out and not draw any lines that would otherwise be out of view, and we can use sine to make our lines squish closer and closer together as they approach the edge of the sphere:

# Back side of globe; don't draw it
if cos(position) < 0:
    continue
		
# Squishes the lines near edges
angularOffset = sin(position) * radius # -90, 0, 90
sphericalX = centerX + angularOffset

Here is this code in action:
rotatingContinents

Now we can add a slight tilt to the axis and an eliptical clip,...

graphics.rotate(0.3)
graphics.setClip(Ellipse2D.Float(x, y, baseDiameter, baseDiameter))

... and afterwards, we'll get something that's very earth like:
closeToEarth

Finally, change the background to black and add in some good old fashioned man made climate using a linear gradient paint,...

	# Intentionally create a little man made climate change ~ lol
	climatePaint = LinearGradientPaint(
		Point2D.Float(centerX, globeY),
		Point2D.Float(centerX, globeY + diameter),
		[0.00, 0.08, 0.20, 0.50, 0.80, 0.92, 1.00],
		[	color(255,255,255),		# North Pole
			color(220,225,210),		# Tundra
			color(110,145,75),		# Temperate
			color(55,115,55),		# Tropical
			color(110,145,75),		# Temperate
			color(220,225,210),		# Tundra
			color(255,255,255)])	# South Pole
	graphics.paint = climatePaint

...and voila, we have our planet:
OurPlanet

All that's missing are the clouds, but for this tutorial, we've had enough fun with geometric shapes. It's time to turn our attention to text. It's gonna work the same way and use the same line mapping function I developed to scan the image. However, text is far more likely to be dynamic, so we need a helper function to convert text into the image input our vertical line map requires:

# Draw a given string within the image for vertical line mapping
def getTextImage(text, width, height, font):
	image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
	graphics = image.createGraphics()
	graphics.font = font
	
	# Center the text within the image
	metrics = graphics.getFontMetrics(font)
	textWidth = metrics.stringWidth(text)
	textHeight = metrics.getAscent()
	x = int((width - textWidth) / 2)
	y = int((height - textHeight) / 2 + metrics.getAscent())
	
	# This is used in tandum with the line mapping function that requires black and white images,
	# ...but if this were needed in a broader way, this color could be made as an argument in the function parameters
	graphics.setColor(color(0, 0, 0))
	graphics.drawString(text, x, y)
	return image

I can imagine some naive person calling this from the repaint event, and I'll admit that, out of pure morbid curiosity, I tested it while refreshing the canvas at 100 frames per second, and it actually worked. However, this is obviously not the correct way to approach dynamic mapping. If the text can change based on user input, tag values, or whatever, use the propertyChange event for the text change to map the text, and store the new map as a custom string property on the canvas. That will automatically trigger a repaint and render the new text without having to expend the computational power required to remap the text with every subsequent repaint.

That said, I doubt we'll want text to wrap all the way around a sphere the way we want continents to wrap all the way around a planet, so we can't treat each line in a text map as a 360 degree collection of longitude lines. We can, however, calculate the circumference of the sphere in the traditional way, or in our case, we'll have to use the circumference we defined for our wrapped continent image if we want our text to rotate at the same speed. I can imagine scenarios where we'd want things to rotate at different speeds, and that's simple: use a larger or smaller angle increment for the circumference of the sphere when calculating the offset angle.

Therefore, the position of each line along the adjusted circumference will determine the width of the text, and in our case, we're gonna offset that a little bit and use a nice, thick, round-capped stroke to make our letters cloud-like:

	# Some hocus pocus is needed here to keep the rotational speed the same as the rotating land mass underneath
	# ...without having to stretch the letters all the way around the globe
	globeCircumference = len(animations.rotatingGlobe.getContinentMap().split(":"))
	rotation = (longitudeOffset / float(globeCircumference)) * PI * 2.0
	
	# We are going to stretch it out a bit, and give the letters a big curly font,
	# ...to give them a cloud like texture
	graphics.color = color('white')
	graphics.stroke = BasicStroke(6, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
	
	
	for wordIndex, wordMap in enumerate(wordMaps):
		wordLines = wordMap.split(":")
		width = len(wordLines)
		
		offsetY = wordOffsets[wordIndex]
		
		# Controls how wide the word appears on the globe.
		# Smaller = tighter letters.
		wordSpan = PI * 0.65

We'll also call the function twice, offsetting the second instance by half the circumference so the text appears on both sides of the planet:

# Paint Hello World
globeCircumference = len(animations.rotatingGlobe.getContinentMap().split(":"))
verticalOffset = -10  # Added a slight offset because it looks better centered between the ice caps than centered perfectly on the equater
paintHelloWorld(graphics, centerX, y + verticalOffset, diameter, longitudeOffset)
paintHelloWorld(graphics, centerX, y + verticalOffset, diameter, longitudeOffset + globeCircumference / 2)

Final Result:
finalFinalDraft

As always, here is the complete animations.rotatingGlobe library script I developed for this tutorial, along with the repaint event that calls it.

Paintable Canvas Repaint Event:

graphics = event.graphics
diameter = 200
x = (event.width / 2) - (diameter / 2)
y = (event.height / 2) - (diameter / 2)
animations.rotatingGlobe.paintGlobe(graphics, x, y, diameter, event.source.frameIndex)

rotatingGlobe_LibraryScripts.zip (15.6 KB)

A neat trick I've used before - a dataset custom property in Vision can store a byte[] column and will serialize/deserialize just fine. Don't overuse it and put massive chunks of data in, but things like small images are totally fine. Potentially a bit cheaper to reassemble from that than storing strings you have to re-parse.

I like the idea of making that stuff a blob in a database, and just byte streaming it as needed.

...but don't forget, you're the one that nerd sniped me with that voxal map stuff and got me contemplating various uses for vertical lines ~ lol

Because you like playing with the Paintable Canvas, @justinedwards.jle, maybe you can port this to Ignition:

How to create your own simple 3D render engine in pure Java

I'm waiting for the day that I roll into this thread to see someone got Doom running in the paintable canvas.

I mean, I got this working as an experiment with Claude a while back:

Is that voxel space? Are you gonna share how that's done?

voxel_2026-08-10_2313.zip (622.9 KB)

It's the rendering technique from the link I tried to nerd-snipe you with earlier, rinsed through quite a few Claude tokens and some attempts on my own to steer towards something I was happy-ish with, which I never quite arrived at. Image + heightmap data is stored in a custom property on the paintable canvas and was taken from the Github repo; in theory you could store and load basically any square images as the tile/heightmap. Your mileage may vary.

It's purely a terrain renderer - no flight model or anything more interesting. I was going to see about how to fake a 'roll' axis, but got sidetracked by real work and a vacation.

Right now put it in preview mode and use the keyboard to control:

        if KeyEvent.VK_SHIFT in d:   boost = 2.0     # afterburner
        if KeyEvent.VK_CONTROL in d: boost = 0.4     # brake
        if KeyEvent.VK_W in d: c.forward(boost)      # forward
        if KeyEvent.VK_S in d: c.forward(-boost)     # back
        if KeyEvent.VK_A in d: c.steer(boost)        # turn left
        if KeyEvent.VK_D in d: c.steer(-boost)       # turn right
        if KeyEvent.VK_Q in d: c.pitch(3.0)          # nose up
        if KeyEvent.VK_E in d: c.pitch(-3.0)         # nose down

Got really nerd sniped by this.

Implemented a pseudo-rotation for roll by skewing both x and y directions based on roll amount.
The roll is programmed to scale with turn speed and will decay back to 0 when the turn key is released.
Also renamed a bunch of variables to make it easier to follow.

It's not letting me upload the zip, may just paste the changed code.

voxel internalFrameActivated
from java.awt import KeyboardFocusManager, KeyEventDispatcher
from java.awt.event import KeyEvent, ActionListener
from javax.swing import Timer

canvas = event.source.rootContainer.getComponent('Paintable Canvas')
DKEY, TKEY = 'voxel.keynav.dispatcher', 'voxel.keynav.timer'

# tear down a prior install so re-activation never stacks dispatchers/timers
kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager()
oldD = canvas.getClientProperty(DKEY)
if oldD is not None:
    kfm.removeKeyEventDispatcher(oldD)
oldT = canvas.getClientProperty(TKEY)
if oldT is not None:
    oldT.stop()

down = set()  # held key codes, shared by the dispatcher and the timer

class _Nav(KeyEventDispatcher):
    def __init__(self, down):
        self.down = down
    def dispatchKeyEvent(self, e):
        from java.awt.event import KeyEvent          # local import: see note
        i = e.getID()
        if i == KeyEvent.KEY_PRESSED:   self.down.add(e.getKeyCode())
        elif i == KeyEvent.KEY_RELEASED: self.down.discard(e.getKeyCode())
        return False                                  # never consume the event

class _Tick(ActionListener):
    def __init__(self, canvas, down):
        self.canvas, self.down = canvas, down

    def actionPerformed(self, e):
        from java.awt.event import KeyEvent
    	c = self.canvas
    	c.decayRoll()
        d = self.down
        if not d:
            return
        # Shift/Ctrl are just keys in the held-set -> use them to throttle speed.
        boost = 1.0
        if KeyEvent.VK_SHIFT in d:   boost = 2.0     # afterburner
        if KeyEvent.VK_CONTROL in d: boost = 0.4     # brake
        if KeyEvent.VK_W in d: c.forward(boost)      # forward
        if KeyEvent.VK_S in d: c.forward(-boost)     # back
        if KeyEvent.VK_A in d: c.steer(boost)        # turn left
        if KeyEvent.VK_D in d: c.steer(-boost)       # turn right
        if KeyEvent.VK_Q in d: c.pitch(3.0)          # nose up
        if KeyEvent.VK_E in d: c.pitch(-3.0)         # nose down

disp = _Nav(down)
kfm.addKeyEventDispatcher(disp)
timer = Timer(16, _Tick(canvas, down))
timer.setCoalesce(True)
timer.start()

canvas.putClientProperty(DKEY, disp)
canvas.putClientProperty(TKEY, timer)
Canvas repaint method
# ============================================================================
# VoxelSpace flyaround -- Paintable Canvas "paint" event (PURE RENDER).
# Ported from https://github.com/s-macke/VoxelSpace
#
# This event is a pure function of (maps dataset + camera custom properties):
# it reads them and draws one frame. It owns NO movement, timing, or camera
# state, and never mutates a custom property (so it can't trigger a render loop
# via PMIPaintableCanvas.setPropertyValue -> repaint). The Signal Generator
# integrates input into the camera eabufferHeight tick and forces the repaint.
#
# CAMERA MODEL -- custom properties on this canvas (doubles, writable by anyone:
# the Signal Generator, key events, buttons, scripts):
#     camX camY camHeight camAngle camHorizon
# DATASET CONTRACT -- custom prop 'maps' (Dataset), one ROW per level, columns:
#     "color"  : PNG file bytes for the color map  (e.g. C10W.png)
#     "height" : PNG file bytes for the height map (e.g. D10.png)
# The PNGs may be different resolutions (height map = world grid; color map is
# scaled to matbufferHeight). Coordinates wrap modulo, so dimensions need not be 2^n.
#
# The decoded PNGs are memoized in CLIENT properties (mapColor/mapHeight/...):
# that is a private resource cabufferHeighte, not part of the observable camera model, and
# client properties do NOT go through setPropertyValue, so cabufferHeighting never repaints.
# ============================================================================
from java.awt.image import BufferedImage
from java.awt import Color, RenderingHints
from java.util import Arrays
from java.lang import System
from javax.imageio import ImageIO
from java.io import ByteArrayInputStream
import jarray
import math

MAXW = 320       # internal render width; scaled up to the component
LEVEL = 0        # heightMapWidthibufferHeight row of 'maps' to load
DISTANCE = 400.0 # render depth (raise for fidelity, lower for speed)
OVERSCAN = 1.5

comp = event.source
g = event.graphics
bufferWidth = event.width
bufferHeight = event.height

maps = comp.getPropertyValue("maps")
rows = maps.getRowCount() if maps is not None else 0

if rows <= LEVEL:
    g.setColor(Color(28, 30, 40))
    g.fillRect(0, 0, bufferWidth, bufferHeight)
    g.setColor(Color.heightMapWidthITE)
    g.drawString("Voxel flyaround: assign the 'maps' dataset (needs row %d) to begin." % LEVEL,
                 16, 28)
else:
    # ---- decode once, or again if the dataset was reassigned ----
    sig = System.identityHashCode(maps)
    if comp.getClientProperty("mapColor") is None or comp.getClientProperty("mapSig") != sig:
        indexColor = maps.getColumnIndex("color")
        indexHeight = maps.getColumnIndex("height")
        if indexColor >= 0 and indexHeight >= 0:
            colorMap = ImageIO.read(ByteArrayInputStream(maps.getValueAt(LEVEL, indexColor)))
            heightMap = ImageIO.read(ByteArrayInputStream(maps.getValueAt(LEVEL, indexHeight)))
            if colorMap is not None and heightMap is not None:
                colorMapWidth = colorMap.getWidth()
                colorMapHeight = colorMap.getHeight()
                heightMapWidth = heightMap.getWidth()
                heightMapHeight = heightMap.getHeight()
                comp.putClientProperty("mapColor", colorMap.getRGB(0, 0, colorMapWidth, colorMapHeight, None, 0, colorMapWidth))
                comp.putClientProperty("mapHeight", heightMap.getRGB(0, 0, heightMapWidth, heightMapHeight, None, 0, heightMapWidth))
                comp.putClientProperty("mapW", heightMapWidth)
                comp.putClientProperty("mapH", heightMapHeight)
                comp.putClientProperty("mapcolorMapWidth", colorMapWidth)
                comp.putClientProperty("mapSx", colorMapWidth / float(heightMapWidth))
                comp.putClientProperty("mapSy", colorMapHeight / float(heightMapHeight))
                comp.putClientProperty("mapSig", sig)

    color = comp.getClientProperty("mapColor")
    if color is None:
        g.setColor(Color(28, 30, 40))
        g.fillRect(0, 0, bufferWidth, bufferHeight)
        g.setColor(Color.heightMapWidthITE)
        g.drawString("'maps' row needs 'color' and 'height' PNG-byte  columns that decode.", 16, 28)
    else:
        heightpx = comp.getClientProperty("mapHeight")
        w = comp.getClientProperty("mapW")
        h = comp.getClientProperty("mapH")
        colorMapWidth = comp.getClientProperty("mapcolorMapWidth")
        mappingScaleX = comp.getClientProperty("mapSx")
        mappingScaleY = comp.getClientProperty("mapSy")

        # camera pose -- read straight from the custom properties (the model).
        # These have non-null double defaults on the component, so no None-guards.
        camx = comp.getPropertyValue("camX")
        camy = comp.getPropertyValue("camY")
        camH = comp.getPropertyValue("camHeight")
        camYaw = comp.getPropertyValue("camAngle")
        camRoll = comp.getPropertyValue("camRoll") #small angles only
        horizon = comp.getPropertyValue("camHorizon")

        renderWidth = bufferWidth if bufferWidth < MAXW else MAXW
        if renderWidth < 1:
            renderWidth = 1
        renderHeight = int(round(renderWidth * bufferHeight / float(bufferWidth))) if bufferWidth > 0 else 1
        if renderHeight < 1:
            renderHeight = 1

        img = BufferedImage(renderWidth, renderHeight, BufferedImage.TYPE_INT_RGB)
        buf = img.getRaster().getDataBuffer().getData()
        Arrays.fill(buf, 0x9090E0)
        tmpWidth = int(renderWidth*OVERSCAN)
        tmp = jarray.zeros(tmpWidth*renderHeight, 'i')
        Arrays.fill(tmp, 0x9090E0)

        sinYaw = math.sin(camYaw)
        cosYaw = math.cos(camYaw)
        k = renderHeight / 400.0
        horizonpx = horizon * k

        hiddeny = jarray.zeros(tmpWidth, 'i')
        i = 0
        while i < tmpWidth:
            hiddeny[i] = renderHeight
            i += 1

        z = 1.0
        deltaz = 1.0
        while z < DISTANCE:
            #yaw rotation
            plx = (-cosYaw * z - sinYaw * z)*OVERSCAN
            ply = (sinYaw * z - cosYaw * z)*OVERSCAN
            prx = (cosYaw * z - sinYaw * z)*OVERSCAN
            pry = (-sinYaw * z - cosYaw * z)*OVERSCAN
            dx = (prx - plx) / tmpWidth
            dy = (pry - ply) / tmpWidth
            invz = 240.0 * k / z
            i = 0
            while i < tmpWidth:
                hx = int(plx) % w
                hy = int(ply) % h
                renderedHeight = int((camH - (heightpx[hy * w + hx] & 0xff)) * invz + horizonpx)
                #adjust for roll
                renderedHeight = int(renderedHeight - (tmpWidth/2 - i)*camRoll)
                ybot = hiddeny[i]
                ytop = renderedHeight
                if ytop < 0:
                    ytop = 0 
                if ytop < ybot:
                    col = color[int(hy * mappingScaleY) * colorMapWidth + int(hx * mappingScaleX)]
                    off = ytop * tmpWidth + i
                    kk = ytop
                    while kk < ybot:
                        tmp[off] = col
                        off += tmpWidth
                        kk += 1
                if renderedHeight < hiddeny[i]:
                    hiddeny[i] = renderedHeight
                plx += dx
                ply += dy
                i += 1
            z += deltaz
            deltaz += 0.005
            
    	#skew rows of buffer
    	for i in range(renderHeight):
        	for j in range(tmpWidth):
        		#calculate new location
        		newJ = int(round((j-(tmpWidth-renderWidth)/2) - (i-renderHeight/2)*camRoll))
        		if newJ >= 0 and newJ < renderWidth:
        			buf[i*renderWidth + newJ] = tmp[i*tmpWidth + j]
        		pass
		
		

        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                           RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR)
        g.drawImage(img, 0, 0, bufferWidth, bufferHeight, None)

steer custom method
def steer(self, throttle):
    """Rotate the heading. The turn rate scales with speed (faster = sharper
    sweep per call), with a small floor so a parked camera can still turn.

    Arguments:
        self: the Paintable Canvas instance (supplied automatically).
        throttle: signed multiplier (+1 left, -1 right; the key timer passes
            >1 for a Shift boost, <1 for a Ctrl brake).
    """
    TURN = 0.006      # radians per unit of effective speed per call. TUNE ME.
    FLOOR = 1.0       # min effective speed for turning, so speed 0 still rotates.
    spd = self.speed if self.speed > FLOOR else FLOOR
    self.camAngle = self.camAngle + TURN * spd * throttle
    self.roll(1.0*TURN*spd*throttle)
roll custom method
def roll(self, amount):
	"""
	Arguments:
		self: A reference to the component instance this method is invoked on. This argument
		  is automatic and should not be specified when invoking this method.
	"""
	LO = -1.0
	HI = 1.0
	v = self.camRoll + amount
	if v < LO:
		v = LO
	if v > HI:
		v = HI
	self.camRoll = v
decayRoll custom method
def decayRoll(self):
	"""
	Arguments:
		self: A reference to the component instance this method is invoked on. This argument
		  is automatic and should not be specified when invoking this method.
	"""
	#decay roll each time this is called
	if abs(self.camRoll) > 0.01:
		self.camRoll*= 0.8
	else:
		self.camRoll = 0

EDIT: had steering turned off for debugging, turned it back on and slightly reduced the turn strength.