Wrapping complex geometric shapes and text around a sphere

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:

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:

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:

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:

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)