"
I love the mountains. I love them so much that I decided to make a new way to interact with them while I'm sitting at my computer.
Going into the mountains to rock climb, trail run, back country ski etc requires a lot of planning. Typically this planning gets done while you're sitting at a computer since software has gotten really good at giving you an idea of the type of terrain you're going to be exploring on any particular outting. Tools like Google Maps & Earth, FatMap, and Avalanche Canada make going to the mountains safer and more enjoyable. I can't tell you how many hours I've saved myself by scoping out a ski tour on Google Earth before committing to it. Without those tools I would have to rely on a paper map and compass, which is terribly slow and usually results in me getting lost in a white-out.
I wanted to be able to see the actual scale of the topography in 3D, not just on a screen. I was originally inspired by the TRANSFORM table from MIT [1]. TRANSFORM is an actuated surface that responds to user interaction by changing the height of individual square pegs that are embedded in the table. If I had access to this table my job would be pretty easy, at least it would be constrained to software development. But alas, I must put on my CAD hat and jump back into Blender.
The basic idea is to create a physical bar chart that can represent a mountains elevation profile, or skyline.
The original idea was to have an array of actuated rods just like the TRANSFORM table, but I crunched the numbers and realized it was out of budget
for a prototype (financially & chronologically). Even with my simplified "2D" design, this was between 50 - 70 hours worth of printing, and a couple rolls of PLA.
I also had a feeling that debugging this thing was going to be complicated even with 10 motors, so I should probably figure out the kinks on a small scale first.
You can move this around btw ------->
Speaking of kinks...
Here's some things that gave me nightmares during this process:
I could put it all in writing, but I figured a video would be more interesting. So here's a more technical demo video of the system, with some details about how I made it happen.
Here’s what’s inside the box:
The circuit schematic.
Early lead screw design.
The problem with this design became obvious as soon as I turned on one of the stepper motors.
The 28BYJ-48 motors have a pretty high gear ratio, it takes 4096 steps (in half step mode) to do one
full revolution at the shaft. This screw is single threaded and had a pitch of about 5mm's. Because
of this I got barely any linear travel per motor step, so my rods would be painfully slow.
The shaft should not be threaded.
Friction, as it turns out, is an enemy of the republic.
While my rendering looks quite pretty if I do say so myself,
this design is quite impractical when dealing with the shabby-at-best precision of an FDM printer.
The final screw & shaft design.
The shaft only has half a centimeter of thread, the rest is smooth and has enough tolerance
for the screw to easily slide through it.
The lead screw is a 7-start thread with 35mm of pitch, a massive increase in the linear travel
per revolution.
After the physical construction was nearing a point of "I think this will probably work" I started on the software side. Basically the whole project can be divided into 3 components
const int stepsPerRevolution = 4096; // steps per shaft revolution for the 28BYJ-48 in half-step mode
const float distancePerRevolution = 35; // 35mm
const float distancePerStep = 0.0085; // mm's
const int maxHeight = 130; // 130mm
const int stepCount = 8; // 8 steps per motor revolution
// 1 turns a coil on, 0 turns it off.
const int stepSequence[8][4] = {
{1, 0, 0, 0}, // Step 1
{1, 1, 0, 0}, // Step 2
{0, 1, 0, 0}, // Step 3
{0, 1, 1, 0}, // Step 4
{0, 0, 1, 0}, // Step 5
{0, 0, 1, 1}, // Step 6
{0, 0, 0, 1}, // Step 7
{1, 0, 0, 1} // Step 8
};
const int maxElevation = 3000; // max elevation in meters
const int minElevation = 2000; // min elevation in meters
int elevations[10] = {0,0,0,0,0,0,0,0,0,0}; // elevations from the MacOS app
int positions[10] = {0,0,0,0,0,0,0,0,0,0}; // Converted to mm's with my scale
class Rod {
public:
// Default constructor
Rod() : current_pos(0), target_pos(0), IN1(0), IN2(0), IN3(0), IN4(0) {}
// Constructor to initialize a rod. P1-P4 are the chosen pins on the arduino. start_pos should probably be 0.
Rod(float start_pos, int P1, int P2, int P3, int P4) {
current_pos = start_pos;
target_pos = 0;
IN1 = P1;
IN2 = P2;
IN3 = P3;
IN4 = P4;
}
// Go to the maximum rod height
void go_to_max() {
target_pos = maxHeight;
int steps = steps_from_target();
int direction = direction_from_target();
stepMotor(steps, direction);
}
// Go to the minimum rod height
void go_to_zero() {
target_pos = 0;
int steps = steps_from_target();
stepMotor(steps, -1);
}
// Go to a specified position in mm's
void go_to_position(int pos) {
target_pos = pos;
int steps = steps_from_target();
int direction = direction_from_target();
stepMotor(steps, direction);
}
private:
float current_pos; // The current position in mm's
float target_pos; // The target position in mm's
int IN1;
int IN2;
int IN3;
int IN4;
void stepMotor(int steps, int direction) {
for (int i = 0; i < steps; i++) {
int stepIndex;
if (direction > 0) {
stepIndex = i % stepCount;
} else {
stepIndex = (stepCount - (i % stepCount) - 1) % stepCount;
}
// Set the coils based on the step sequence
digitalWrite(IN1, stepSequence[stepIndex][0]);
digitalWrite(IN2, stepSequence[stepIndex][1]);
digitalWrite(IN3, stepSequence[stepIndex][2]);
digitalWrite(IN4, stepSequence[stepIndex][3]);
// Delay between steps (adjust for speed)
delay(1);
}
// De-energize the coils after movement
digitalWrite(IN1, LOW);
digitalWrite(IN2, LOW);
digitalWrite(IN3, LOW);
digitalWrite(IN4, LOW);
current_pos = target_pos; // Equalize after motor is done.
}
// Return the number of steps the motor should spin
int steps_from_target() {
float distance = abs(current_pos - target_pos);
return round(distance / distancePerStep);
}
// Return the direction the motor should spin
int direction_from_target() {
if (target_pos > current_pos) {
return 1;
} else {
return -1;
}
}
};
const int numRods = 10; // How many rods I have
Rod rods[numRods]; // initialize the list of rods because C++ sucks.
void initializeRods() {
rods[0] = Rod(0, 2, 3, 4, 5);
rods[1] = Rod(0, 6, 7, 8, 9);
rods[2] = Rod(0, 10, 11, 12, 13);
rods[3] = Rod(0, 14, 15, 16, 17);
rods[4] = Rod(0, 18, 19, 20, 21);
rods[5] = Rod(0, 22, 23, 24, 25);
rods[6] = Rod(0, 26, 27, 28, 29);
rods[7] = Rod(0, 30, 31, 32, 33);
rods[8] = Rod(0, 34, 35, 36, 37);
rods[9] = Rod(0, 38, 39, 40, 41);
}
// Set the Mega's digital pins as outputs
void set_mega_pins_to_output() {
for (int pin = 0; pin <= 53; pin++) {
pinMode(pin, OUTPUT);
}
}
// Set the Uno's digital pins as outputs
void set_uno_pins_to_output() {
for (int pin = 0; pin <= 13; pin++) {
pinMode(pin, OUTPUT);
}
}
void setup() {
Serial.begin(9600);
set_mega_pins_to_output();
initializeRods();
delay(2000);
}
void loop() {
// calibrate_rods();
if (Serial.available() > 0) {
String data = Serial.readStringUntil('\n'); // Read until newline character
Serial.println("read data successfully");
data.trim(); // Remove any leading/trailing whitespace
// Remove square brackets
data.replace("[", "");
data.replace("]", "");
Serial.println("replaced []");
int numElevations = parseElevations(data);
Serial.print("numElevations: ");
Serial.println(numElevations);
// Process the elevations if they exist
if (numElevations > 0) {
Serial.println("Received elevations:");
for (int i = 0; i < numElevations; i++) {
positions_from_elevations(elevations, positions, numRods); // Map the elevations
Serial.println("Updated positions");
update_rod_positions(positions); // Move the rods to the new positions.
Serial.println("Moved the rods");
}
}
}
}
void update_rod_positions(int positions[]) {
for (int i = 0; i < numRods; i++) {
Rod &rod = rods[i]; // Make a reference to the specific rod
rod.go_to_position(positions[i]);
}
}
void positions_from_elevations(int elevations[], int positions[], int size) {
for (int i = 0; i < size; i++) {
if (elevations[i] == 0) {
positions[i] = 0;
} else if (elevations[i] == 1) {
positions[i] = maxHeight;
} else {
int clampedElevation = constrain(elevations[i], minElevation, maxElevation);
positions[i] = map(clampedElevation, minElevation, maxElevation, 0, maxHeight);
}
}
}
// Parse the elevations and update the elevations array.
// Return the number of elevation points
int parseElevations(String data) {
int count = 0;
while (data.length() > 0 && count < numRods) {
int commaIndex = data.indexOf(",");
String valueStr;
if (commaIndex == -1) {
// Last value
valueStr = data;
data = "";
} else {
valueStr = data.substring(0, commaIndex);
data = data.substring(commaIndex + 1);
}
valueStr.trim(); // Remove any extra whitespace
elevations[count] = valueStr.toInt();
count++;
}
return count;
}
void calibrate_rods() {
//rods[0].go_to_position(-150);
// rods[1].go_to_position(-40);
// rods[2].go_to_position(-60);
// rods[3].go_to_position(-20);
rods[4].go_to_position(-40);
//rods[5].go_to_position(-1);
//rods[6].go_to_position(-90);
// rods[7].go_to_position(-9);
//rods[8].go_to_position(-25);
// rods[9].go_to_position(-10);
}
[1] Hiroshi Ishii, Daniel Leithinger, Sean Follmer, Amit Zoran, Philipp Schoessler, and Jared Counts. 2015. TRANSFORM: Embodiment of "Radical Atoms" at Milano Design Week. In Proceedings of the 33rd Annual ACM Conference Extended Abstracts on Human Factors in Computing Systems (CHI EA '15). Association for Computing Machinery, New York, NY, USA, 687–694. https://doi.org/10.1145/2702613.2702969