This week, our team built a CNC Sand Plotter (Sisyphus style) where a neodymium magnet moves a steel ball through sand to draw patterns.
My specific role: I was responsible for the software architecture and communications. I designed a custom web-based CAM tool ("Sand-Path Mission Control") that runs entirely on the browser to convert images into continuous G-Code, and developed the OTA (Over-The-Air) communication protocol to send this data wirelessly to the ESP32-C6 controller.
CO-OP MISSION: TEAM PROTOCOLS
To see the full mechanical design, electronic wiring, and assembly of our machine, please visit our Group Project Page.
Instead of relying on external software, I built my own **Client-Side Web Application**. This tool allows the user to upload a PNG image, adjust the contrast threshold, preview the generated path, and send the G-Code wirelessly to the ESP32 CNC controller.
*Note: The CNC machine and the laptop must be connected to the same Wi-Fi network for the wireless transmission to work.
02. THE APP INTERFACE (UI/UX)
Instead of relying on clunky desktop software or installing dependencies, I wanted a modern, accessible, and frictionless solution. I built a Client-Side Web Application using HTML, CSS, and Vanilla JavaScript.
The interface allows users to upload a PNG/JPG file, adjust the contrast threshold via a slider (to define what is considered a "drawing path"), and preview the trajectory. The core technical achievement here is using the HTML5 <canvas> API to read the raw pixel data arrays directly in the browser. By extracting the RGBA values of each pixel, the script identifies the dark paths that the physical magnet should follow, translating digital pixels into a virtual coordinate map without needing any backend server processing.
The Sand-Path Mission Control interface running locally in the browser.
03. THE LOGIC: IMAGE TO G-CODE
THE PERMANENT MAGNET PARADOX
Generating G-Code for a traditional CNC or 3D printer is straightforward: you lift the Z-axis, move to the next coordinate, and lower it. However, dealing with a permanent neodymium magnet beneath a table presents a unique physical limitation: we don't have a Z-Axis. Everything the machine does leaves a mark in the sand. If the magnet jumps from one corner to another, it will draw a straight line through the artwork.
To solve this paradox, I had to write a custom mathematical algorithm in JavaScript:
Edge Detection & Mapping: It scans the canvas to find the boundaries between dark and light pixels, generating an array of valid target coordinates.
Nearest Neighbor Algorithm: Instead of jumping randomly through the array, the code starts at the origin coordinate (0,0). It then mathematically searches the entire array for the closest next point using the Pythagorean distance formula (dx²+dy²). Once found, it moves there and repeats, forcing a single, continuous, non-lifting toolpath.
Physical World Scaling: Finally, a formula scales the 500x500 web pixels into the 180x180 millimeters of our physical CNC bed: ((pixelX / 500) * 180), outputting standard G0/G1 commands.
Simulating the continuous G-Code. Notice how it enters from the origin (0,0) and traces everything without breaks.
04. WIRELESS NETWORKING (OTA)
To completely eliminate the need for a USB connection (since the machine sits on a standalone table), I programmed the ESP32-C6 to act as an asynchronous HTTP Server on our local 2.4GHz Wi-Fi network.
When the user clicks the "Send to ESP32" button on the webpage, a JavaScript fetch() request packages the entire G-Code string and sends an HTTP POST request to the microcontroller's IP address.
Overcoming the CORS Block: One of the biggest challenges in this step was dealing with browser security. Modern browsers block HTTP requests sent from local files (or local web servers) to external IP addresses due to CORS (Cross-Origin Resource Sharing) policies. To bypass this, I had to program the ESP32 to explicitly handle HTTP OPTIONS requests and reply with the headers Access-Control-Allow-Origin: *. This tells the browser that the ESP32 is a safe device, allowing the G-Code payload to pass through flawlessly.
Successful payload transmission over Wi-Fi handled by the ESP32 WebServer.
05. HERO SHOT
Testing the full integration: From image upload, to local continuous G-Code processing, to wireless transmission, and finally, physical movement in the sand!
Software and Hardware working together perfectly.
06. THE CODE VAULT
// 1. NEAREST NEIGHBOR ALGORITHM (JAVASCRIPT)
// Finding the closest next pixel to create a continuous line
while (edgePoints.length > 0) {
let nearestIdx = 0;
let minDist = Infinity;
for (let i = 0; i < edgePoints.length; i++) {
let dx = current.x - edgePoints[i].x;
let dy = current.y - edgePoints[i].y;
let d = dx*dx + dy*dy;
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
current = edgePoints.splice(nearestIdx, 1)[0];
path.push(current);
}
// 2. ESP32 OTA RECEIVER & CORS HANDLING (C++)
#include <WiFi.h>
#include <WebServer.h>
const char* ssid ="WIFI NAME";
const char* password ="PASSWORD";
WebServer server(80);
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.begin(ssid, password);
Serial.print("Conectando al Wi-Fi");
//THE LAPTOP AND THE ESP32 MUST BE ON THE SAME WIFI NETWORK FOR THIS TO WORK
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\n¡Conexion exitosa!");
Serial.print("=== TU IP DE HOY ES: ");
Serial.print(WiFi.localIP());
Serial.println(" ===");
// PREFLIGHT CORS HANDLER
server.on("/upload", HTTP_OPTIONS, []() {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Max-Age", "10000");
server.sendHeader("Access-Control-Allow-Methods", "POST,GET,OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "*");
server.send(204);
});
// GCODE PAYLOAD HANDLER
server.on("/upload", HTTP_POST, []() {
server.sendHeader("Access-Control-Allow-Origin", "*");
if (server.hasArg("plain")) {
String gcodeRecibido = server.arg("plain");
Serial.println("\n=== NUEVA MISION RECIBIDA ===");
Serial.println(gcodeRecibido);
Serial.println("=============================");
server.send(200, "text/plain", "¡Mision Recibida por la ESP32 de Javi!");
} else {
server.send(400, "text/plain", "Error: El payload llego vacio");
}
});
server.enableCORS(true);
server.begin();
}
void loop() {
server.handleClient();
}