A Spike Prime XY Plotter

Introduction

 

In this project, we created an XY Plotter using only a Spike Prime Set (45678), a felt pen and a QikEasy Adapter. The plot data is streamed real time from a computer.  This build should also work on Mindstorms Robot Inventor Set (51515).

 

Given the limitation due to the less than desirable rigidity LEGO blocks in general, though the output drawings are perfect, we are still quite satisfied with the outcome of the project. The robust data streaming ability of the QikEasy Adapter is exemplified by how effectively it is in continuously receives data over Wifi for an extended period of time (some of our experimented drawings took almost an hour prior to the G-Code optimization we did).

 

Architectural Overview

 

Here’s the flow chart to show the relevant components and how data flows from the drawing file to our plotter:

Required Materials

 

 

To follow this tutorial, you must prepare these before starting the project:

 

    • Your LEGO Spike Prime or Robot Inventor system.

 

    • Your QikEasy Adapter must be setup to run in Virtual Wireless Sensor mode.  See instructions on how to set that up here.

 

    • A computer running our Python program to stream the plotted movements data to your QikEasy Adapter.

 

Both your computer and the QikEasy Adapter should all be connected to the same Wifi network.

Building your LEGO Robot

 

 

Download the Construction Guide PDF here.

 

 

 

 

Please refer to the Construction Guide for building this LEGO model.  You may need to adjust the design of the pen holder components depending on the shape and size of the pen you will be using.

 

Ports Used in our build:

Port A ==> Connects to the motor underneath the drawing platform.
Port B ==> Connects to QikEasy Adapter
Port C ==> Connects to the motor that raises and lowers the pen
Port E ==> Connects to the motor that moves the pen along the X axis

 

Other Tips:

    • Use two rubber bands to help apply downward pressure on the top part of the pen holder.
    • To minimize the wiggling of the x movement construct, we used a hair band to pull the rail towards the front side so that it will have lesser chance of wiggling towards the back side.
    • You may also need to use scotch tapes to ensure that the drawing platform staying down so that the motor’s gear underneath it will always have a firm grip to move the platform.

G-Code as Input Data File Format

 

We created a Python program (plotGcode.py) that runs on the computer to parse G-code file command and stream them to the LEGO hub.  The streamed data is for controlling the pen and drawing platform movements on our LEGO model. There are basically only 2 types of movements:
    • Pen Up or Pen Down
    • Move to (x, y) location

 

Note that upon startup, pen is lifted up.

 

Our program only need to understand G1 commands in a G-Code file.  There are typically 2 kinds of G1 commands.  Here are some examples:

; Pen Up
G1 Z2.5400 F2540

; Pen Down
G1 Z-3.1750 F127

; Move to XY position
G1 X163.6547 Y-148.5491 F127
G1 X41.288 Y-21.873

 

Note that our code completely ignores the F value, which represents the speed of the movement.

Here’s an example input G code file:

G21             ; Set units to mm

G90             ; Absolute positioning

G1 Z2.54 F2540  ; Move to clearance level

G1 X1024 Y1024  ; Move to 1024, 1024

G1 Z-1          ; Pen down

G1 X0 Y1024     ; Move to 0, 1024

G1 X0 Y0        ; Move to 0, 0

G1 X1024 Y0     ; Move to 1024, 0

G1 X1024 Y1024  ; Move to 1024, 1024

G1 X0 Y0        ; Move to 0, 0

G1 Z2           ; Pen Up

G1 X1024 Y0     ; Move to 1024, 0

G1 Z-1          ; Pen down

G1 X0 Y1024     ; Move to 0, 1024

G1 Z2           ; Pen Up

M2

This plot will draw lines around the 4 sides of the rectangle, and draw the 2 diagonal lines.

For testing different sloped lines on the plotter, these G-code files can be used.

G21             ; Set units to mm

G90             ; Absolute positioning

G1 Z2.54 F2540  ; Move to clearance level

G1 X700 Y1024   ; Move to 700, 1024

G1 Z-1          ; Pen down

G1 X300 Y1024   ; Move to 300, 1024

G1 X300 Y0      ; Move to 300, 0

G1 X700 Y0      ; Move to 700, 0

G1 X700 Y1024   ; Move to 700, 1024

G1 X300 Y0      ; Move to 300, 0

G1 Z2           ; Pen Up

G1 X700 Y0      ; Move to 700, 0

G1 Z-1          ; Pen down

G1 X300 Y1024   ; Move to 300, 1024

G1 Z2           ; Pen Up

M2

G21             ; Set units to mm

G90             ; Absolute positioning

G1 Z2.54 F2540  ; Move to clearance level

G1 X1024 Y700   ; Move to 1024, 700

G1 Z-1          ; Pen down

G1 X0 Y700      ; Move to 0, 700

G1 X0 Y300      ; Move to 0, 300

G1 X1024 Y300   ; Move to 1024, 300

G1 X1024 Y700   ; Move to 1024, 700

G1 X0 Y300      ; Move to 0, 300

G1 Z2           ; Pen Up

G1 X1024 Y300   ; Move to 1024, 300

G1 Z-1          ; Pen down

G1 X0 Y700      ; Move to 0, 700

G1 Z2           ; Pen Up

M2

How to Prepare a G-Code File for use with Your LEGO Plotter

 

Besides manually creating the data file as we did above, here’s the general procedure we used to create these files:

    1. Find a suitable SVG file. Note that it is preferably to find an SVG that is a line art that doesn’t have thick lines or large shaded areas.  Due to the limitation of LEGO, do not expect a super accurate reproduction of the rendered drawing.
    2. Convert the SVG file to become a G-code file. Use a converter like JsCut at https://jscut.org/jscut.html.  To use this tool, first open the SVG file from the top bar menu.
      • You may need to follow the instructions on this page to prepare your SVG file.
      • Once the SVG file is prepare, you can use JsCut to do the conversion.  Simply, select all the toolpaths in the “Edit Toolpaths” tab, the create the “Create Operation” button on the left.  And click “Generate”.  The conversion should have completed. If you switch to the “Simulate GCODE” tab on the right side, you should see a simulation of the toolpaths.  Click “Save GCODE” on top to save the G-code file.
    3. Minify G-code file. A automatically converted G-code file usually would have too many unnecessary segments that are closely packed together.  With such, it would take a lot longer to draw our painting on the plotter than necessary. To improve the speed of plotting, we have created a program to eliminate the unnecessary xy positioning commands in the G-code file. Depending on how aggressive the reduction factor we use, the output G-code file generated by this automatic process usually has undesirable artifacts.  It is often preferable to perform some manually fixing on the out G-code file.  In the next section, we will talk more about how to reduce the size of the G-code file.
    4. Preview your Plot.  Run the following command to render the file that you created in Step 3:

      python plotGcode.py render [G-code Filename]

      Replace the square bracket value with the input G-code file name.

    5. Plot your Drawing.  Start your Word Block program (See further instructions below).  Once the calibration finishes, run the following command on your computer to plot the file that you created in Step 3:

      python plotGcode.py plot [G-code Filename]

      Replace the square bracket value with the input G-code file name.

Reducing G-code file size

 

As mentioned in the last section, automatically converted G-code file usually would have too many unnecessary segments that are closely packed together.  To improve the speed of plotting, we provided a program to eliminate the unnecessary xy positioning commands in the G-code file. Additionally, it is often preferable to perform some manually fixing on the out G-code file.  In the this section, we will talk more about how to run this program, and how you can manually optimize your G-code file.  Note that this reduction process outputs a file only good for our Plotter program.  The program we provide complete omits the “speed” parameter of the G1 command in the G-code file, as we don’t use this information.

 

      1. Run the Auto Minify Program. Run the following command to perform automatic G-code file minification.

        python gcodeMinify.py [minimum segment length] [input g-code file] > [output g-code file]
        
        For example:
           python gcodeMinify.py 5 mona-lisa.gcode > minified-mona-lisa.gcode

        Be sure to try different values of [minimum segment length].  The bigger the length, the smaller will be the number of movements but rougher the output drawing.  The trick is to find the right balance by trial and error, utilizing the “plotGcode.py render” command to preview the plot. Also, you may use the “wc” command to count the number of lines in the output G-Code file.

      2. Review and Preview your Plot.It is recommended that you clean up your G-code file after the minification process. For a free online G-code viewer and editor, we used NC Viewer.  You can visit its website here:  https://ncviewer.com/ .  Its usage is fairly straight forward.  After you open the file, you can click a point on the simulation, and the corresponding line in the G-code will be highlighted on the left pane.  You can then edit the XY coordinate of that point accordingly in the text editor.  After you are done, click the “Plot” button to update the rendering.

        Below are two pictures illustrate the G-code rendering before and after manual optimization.  It shows why it is necessary to do such tuning. The first picture shows the simulation after running the above command with 5 as minimum segment length.  The second picture is the rendering of the version of G-code that has been manually fixed based on the G-code from the first picture.

Download Example G-Code Files

Besides the box and diagonal lines example, you may download additional example G-code files here.
The zip file includes the bank and the Mona Lisa drawings that are presented in the video.

The Python Programs

 

 

THE PYTHON PROGRAMS

 

These are the two Python program we have.  The usage of these programs had already been explained in the above sections:

        • gcodeMinify.py – for reducing the size of the G-code file.
        • plotGcode.py – for either rendering the G-code file on screen or plotting the G-code file on the plotter.

Customizing Settings for your Environment:

You will need to customize the following setting in the plotGcode.py code before you run the program:

        • VIRTUAL_ SENSOR_IP_ADDRESS –  This should be set to your QikEasy Virtual Wireless Sensor’s IP address.

 

Here are the code:

 

####################################################################################################
# gcodeMinify.py 
#
#	python gcodeMinify.py [minimum segment size] [input gcode file name] > [output gcode file name]
####################################################################################################

import sys
import os
import math


# Check if there are enough arguments
if len(sys.argv) != 3:
    print(f"Usage: python {sys.argv[0]} [minimum segment size] [input gcode file name]")
    exit()

# Get the input parameters
minSegmentLength = sys.argv[1]
filename = sys.argv[2]

# Check if minSegmentLength is a valid float
try:
    minSegmentLength = float(minSegmentLength)
except ValueError:
    print("[minimum segment size] must be a floating point number!")
    exit()


# Check if the file exists and is readable
if not(os.path.isfile(filename) and os.access(filename, os.R_OK)):
    print(f"The file {filename} does not exist or cannot be read!")
    exit()


# Calculate distance between point1 and point2
def distance(point1, point2):
    x1, y1 = point1
    x2, y2 = point2
    return round( math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2), 1 )
    
def printG1Line( pos ):
	print(f'G1 X{pos[0]} Y{pos[1]}')
			


with open(filename, 'r') as f:
	prevPos=(-9999,-9999)
	bufferedSkippedPos = None
	penUp = False
	
	# Loop through each segment
	for line in f:
		# G1 lines are the lines that is responsible for moving the plotter xyz location
		if line.startswith('G1'):
			# Handle lines with XY values first
			x_str = line.split('X')[-1].replace('\t', ' ').split(' ')[0]
			try:
				x = float(x_str)
				y_str = line.split('Y')[-1].replace('\t', ' ').split(' ')[0]
				y = float(y_str)

				if penUp == True:
					printG1Line( (x, y) )
					prevPos = (x, y)
				else:
					length = distance( prevPos, (x,y) )
					if length>minSegmentLength: # or i == len(lines) - 1 or lines[i+1][1]==9999:
						printG1Line( (x, y) )
						prevPos = (x, y)
						bufferedSkippedPos = None
					else:
						bufferedSkippedPos = (x, y)	

			except ValueError:
				
				# Handle Z movement lines
				try:
					z_str = line.split('Z')[-1].replace('\t', ' ').split(' ')[0]
					z = float(z_str)
					if z > 0:
						penUp = True
					else:
						penUp = False
				except vError:
					pass
				
				if bufferedSkippedPos!=None:
					printG1Line( bufferedSkippedPos )
					prevPos = bufferedSkippedPos
					bufferedSkippedPos = None
				print(line)
				
		else:
			if bufferedSkippedPos!=None:
				printG1Line( bufferedSkippedPos )
				prevPos = bufferedSkippedPos
				bufferedSkippedPos = None
			print(line)

 

###################################################################
# plotGcode.py 
#
#	python plotGcode.py [render or plot] [input gcode file name]
###################################################################

import matplotlib.pyplot as plt
import time
import sys
import os
import requests
import math
import random

VIRTUAL_SENSOR_IP_ADDRESS = "192.168.11.162"
constOutputMaxRange = 1024

# Global Variables
mode = "render"
prevPos=(1024,0)
penUp = False
stepId = 0

# Check if there are enough arguments
if len(sys.argv) != 3:
    print(f"Usage: python {sys.argv[0]} [render or plot] [input gcode file name]")
    exit()

if not(sys.argv[1]=="render" or sys.argv[1]=="plot"):
    print(f"Second parameter must be 'render' or 'plot'.")
    exit()

# Check if the file exists and is readable
if not(os.path.isfile(sys.argv[2]) and os.access(sys.argv[2], os.R_OK)):
    print(f"The file {filename} does not exist or cannot be read!")
    exit()

# Get the input parameters
mode  = sys.argv[1]
filename = sys.argv[2]  

#==========================================
# Calculate distance between two points
#==========================================
def distance(point1, point2):
	x1, y1 = point1
	x2, y2 = point2
	return round( math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2), 1 )
	

#==========================================
# Generate a random between 1 to 255 that doesn't equal to prev number
#==========================================
def my_random_number(prevRandomNumber):
    while True:
        newRandomNumber = random.randint(1, 255)
        if newRandomNumber != prevRandomNumber:
            break
    return newRandomNumber



#==========================================
# command possible input values:
#	   when 0 => position
#	   when 1 => up		(i.e. 11 in raw)
#	   when 2 => down	(i.e. 22 in raw)
#==========================================
def sendPlotCommand( command, x, y ):
	global stepId
	
	reflection = 0
	if command == 1:
		reflection = 11
	elif command == 2:
		reflection = 22
	
	stepId = my_random_number(stepId/255*1024)
	
	endpoint = f"http://{VIRTUAL_SENSOR_IP_ADDRESS}/set?t=c&r={stepId}&g={x}&b={y}&R={reflection}"
	requests.get( endpoint )
	#print( endpoint )

	
	
	
#==========================================	    
# Receive point command either for rendering on screen or for plotting on our xy plotter
#==========================================
def execPointCmd(point):
	global prevPos, penUp

	# Pen up/down command
	if point[1] == 9999:
		if point[0] > 0:
			penUp = True
			if mode == "plot":
				sendPlotCommand(1, 0, 0)
				time.sleep( 0.8 )
		else:
			penUp = False
			if mode == "plot":
				sendPlotCommand(2, 0, 0)
				time.sleep( 0.8 )
			
	# Set xy position
	else:
		# When Pen Down
		if penUp == False:
			# If it's not the start of a line
			if prevPos[0] != -9999:
				if mode == "render":
					plt.plot( (prevPos[0], point[0]), (prevPos[1], point[1]), color='black')
		
		# Perform Plot movements
		if mode == "plot":
			sendPlotCommand( 0, point[0], point[1] )
			motionDuration = distance(prevPos, point) / 200 + 0.2 
			#print ( f"duration= {motionDuration} for line from ({prevPos[0], prevPos[1]}) to ({point[0], point[1]})")
			time.sleep( motionDuration )
		
		prevPos = point




# Global Variables
minX = 9999
minY = 9999
maxX = 0
maxY = 0

# The buffer array that will be used to store our output data
points = []

with open(filename, 'r') as f:

	# Loop through each segment
	for line in f:
		# G1 lines are the lines that is responsible for moving the plotter xyz location
		if line.startswith('G1'):
			# Handle lines with XY values first
			x_str = line.split('X')[-1].replace('\t', ' ').split(' ')[0]
			try:
				x = float(x_str)
				y_str = line.split('Y')[-1].replace('\t', ' ').split(' ')[0]
				y = float(y_str)
				points.append((x, y))		# append to points[] array
				
				# Update minX, minY, maxX, MaxY values
				if ( x > maxX ):
					maxX = x
				if ( y > maxY ):
					maxY = y
				if x < minX:
					minX = x
				if y < minY:
					minY = y
			except ValueError as err:
				# Handle Z movement lines
				try:
					z_str = line.split('Z')[-1].replace('\t', ' ').split(' ')[0]
					z = float(z_str)
					points.append((z, 9999))		# append to points[] array
				except ValueError:
					pass

# Calculate width and height of drawing
maxW = maxX - minX
maxH = maxY - minY


# Calculate the scaling factor in order for the drawing to fit into 1024 range.
# Also calculate offset in order to center the image in the square plot platform
scaleFactor = maxW
if maxH > maxW:
	scaleFactor = maxH
if maxH < maxW:
	offset = (maxW - maxH) * constOutputMaxRange / scaleFactor / 2;
else:
	offset = (maxH - maxW) * constOutputMaxRange / scaleFactor / 2;


# loop through each item in the array again
for i, point in enumerate(points):
 	# NOT Command to set Pen up or down
	if point[1] != 9999:
		# Scale and shift the position into the (0,1024) range.

		# shifting to start from 0 with no negative values
		point = ( point[0] - minX, point[1] - minY )
 
		# Scale segment so that the coordinate fits in the ( constOutputMaxRange x constOutputMaxRange ) grid 
		point = ( point[0] * constOutputMaxRange / scaleFactor, point[1] * constOutputMaxRange / scaleFactor )

		# Center the side that's narrower
		if maxH < maxW:
			point = (point[0], point[1] + offset)	
		else:
			point = (point[0] + offset, point[1])	

	execPointCmd(point)

	

if mode == "render":
	# Set the limits of the axis to fit the drawing
	plt.xlim(0, constOutputMaxRange)
	plt.ylim(0, constOutputMaxRange)
	plt.axis('equal')

	# Show the final result
	plt.show()

The Word Block Program

 

On the LEGO plotter, the Word Block program will be running to receive (via QikEasy Adapter’s Virtual Wireless Sensor functionality) commands and XY positions from the computer.  Based on these commands, it determines the corresponding motions for the motor of 3 axises.

The program shown here is for Spike App 3. To change the program for Spike App 2 or Robot Inventor, you simply have to change the first block of the program to set the rawColorResolution to 255 (instead of 1024).

 

First of all, you will need to customize the code to suit your environment:

 

        • If you are running your program on Spike App 2 or Robot Inventor, change the block immediately after “When Program Starts” to set the max_red value to 255 (instead of 1024).

 

Here’s the whole Word Block program:

 

 

The tricks and logics used in our Word Block program:

        • Being sent to the block program as Virtual Color Sensor’s Raw Red color, it represents the Step Number of each command.  Adjacent Step Numbers are guaranteed to be unique.  We use this property to detect when a new command is sent to the Virtual Sensor.
        • Virtual Color Sensor’s Reflected Light is the command type.  “0” – Set XY location; “1” – Pen Up; “2” Pen Down.
        • Virtual Color Sensor’s Raw Green and Raw Blue colors respectively represent the X and Y coordinates.
        • The main code for driving the x and y motors works like this:
          • A gotoXY function is defined to motorize the x and y motors such that they both motors will arrive at the corresponding destinations at approximately the same time.
          • Part of the challenge is to get both motors running at the same time.  We did that by having the Y motor driven by the code in the function, and the X motor is driven by a broadcast statement, when there is another even that perform the X motor motion upon receiving of the broadcast.
          • The algorithm of the gotoXY function is like this:
            • Set the speed of the X and Y motors based on current and target positions
            • broadcast X target to have the separate event to start the X motor motion
            • Control the Y motor to go to its target destination
            • Upon the destination being reached, update current positions
        • Synchronizing the Speed of the X and Y motors:
          • To plot a line at 45% degrees on XY coordinates, the X motor and Y motor should run at the same speed.  However, even if the speeds are set to the same % value in the Word Block program, this may not always achieve consistent results due to differences in motors and in the design of the X rail and Y movement of the drawing platform.  Through trial and error, our build has these settings:
            • X speed = 20%
            • Y speed = 18%
          • We created a setSpeed function to calculate and set the speed of the x and y motors based on the current position and the target position. The formula for calculating the estimated speed was mostly from trial and error.  There is certainly room for improving this.
        • Upon Startup, the program will need to first calibrate the maximum position of the x and y axis. It does this by running the motors beyond the maximum points of these axises. In order to get the gear to go from one end of the rack (as in rack and pinion setup) to the other end, the Spike Prime motor needs to run for almost 2 full rotations.  Since at startup, we don’t know the starting position of the pen, during this setup phase, we have to make sure the pen exceeds one end of the rack. Therefore, it is normal that the gear slipping sound for a couple seconds during startup.
          • The resetXY function is responsible for the initial setup of the motor that moves the pen to one end of the racks on both X and Y axis.  We use a similar trick as gotoXY function by using the broadcast message in order to get both motors to run simultaneously.

Starting the Plot

 

To plot your G-Code file, follow these steps:

 

      1. Start the Word Block program on your Hub.  Make sure the first block is set to the correct value of either 1024 or 255 based on the version of Spike App you have.
      2. The platform and the X rail should start moving to calibrate the starting position to (1024, 0).  Wait for all the calibration to complete.
      3. Check to make sure all the LEGO parts and secure, especially the parts that we use scotch tape and hair band.
      4. On your computer, run the command to stream your plot data to your LEGO plotter.  For example (replace <myPlot.gcode> with your own gcode file name):

python plotGcode.py plot <myPlot.gcode>

 

Enjoy and watch for the plotter to plot your drawing!

Challenge Extensions

 

These are some of our suggestions for extending this project:

 

      • The plotting of lines with different slopes can be improved. These are some potential strategies you may try:
        • The speed calculation for different slopes can be better tuned. Try use a lookup table to improve the setSpeed function.
        • The speed calculation for different slopes can be better tuned. Try improve the calculation formula.
        • Plotting of a sloped line can be implemented by plotting multiple short segments, as it is easier to get the plotter to accurately go to specific points, than synchronizing the motions of the x motor and y motor.
      • Because of the wiggling of the pen (especially in the x-axis), dragging it in one direction, and then change to a different direction causes significant errors in the positions of the pen. Try change the code so that for every segment to be plotted, first lift up the pen, move it to a specific side (say left side) of the segment on the x-axis, and start plotting only towards the right side. This change will cause the pen to be always dragged in the same direction and should theoretically reduce the positioning error of the pen at a specific x location.

Conclusion

 

QikEasy Adapter’s Virtual Wireless Sensor provides boundless integration opportunities with all sorts of data sources available over the network.  This project presents only one of its possible use.  You may visit our Virtual Wireless Sensor documentation page for more fun and interesting ideas.