How do you move a group of objects synchronously around the inside of a closed space? It’s not as easy as it originally sounded to me.
One of the Meteor projects we’re doing here at Differential involves interacting with 3D visualizations. For me, it’s a beautiful thing to work with 3D, on the web, with reactive interactivity. We’ve come a long way since I first started playing with computer graphics 40 years ago. And like any good domain, there are still good applied problems in it that crop up. It’s one of the reasons that I really love programming.
I’m going to simplify the problem I encountered a little for the purposes of this writing, but lets say what it amounts to is sliding a set of pictures along the walls in a room. I’ll take a few liberties with the process, but I’ll stay true to the problem. I’m also going to take liberties with the code, as it’s more like pidgin english, just intended to get the idea across succinctly.
So with those caveats, let’s jump in.
The 3D Coordinate Space
I was trained way back to visualize 3-space as a left-handed coordinate system with the positive X axis to the right, positive Z axis going up, and positive Y axis coming out at you. This makes intuitive sense to me - Z is elevation above a flat page, like looking at a relief map spread out on a table, and if you orient the system so Y is up and X is to the right, things farther from you have bigger Z values.
In the project, we chose to chose to use three.js to do the rendering, and unfortunately for me, it uses the right-handed coordinate system: the positive X axis is still to the right, but the positive Y axis is going up and the positive Z axis is coming out of the page. Basically, all this means is that when Z values are increasing, objects are getting closer to the observer. (This is how Z order on the web works - although on the web, Y is positive going down the page, and if you rotate Y to up, the web is actually using a left-handed coordinate system.)
This may seem like it’s pretty trivial, but believe me, it’s like having to forget how to ride a bike, and then re-learn a different way. Or like forcing yourself to tie your shoes starting with the laces left-over-right instead of right-over-left. It feels otherworldly and completely unnatural. Your mental muscle memory is severely compromised.
The 3D Room
What all my coordinate system whining really means is that constructing the room felt unnatural to me… but I got through it. Walls in our simplified room will align with planes perpendicular to the X and Z axes. We chose our 3D origin to be in the geometric center of the room. Looking through the front wall of the room to the back, left is in the negative X direction, and right is positive X. The X extent spans the width of the room, and the Z extent its depth. The Y extent is the height of the room.
Given the placement of the origin (0,0,0), the room is divided into eight octants. But we really don’t care any more about this except that we always start our visualization by looking at the origin as the object point from some other point as the viewpoint. It’s convenient to think of the room as symmetrically surrounding the 3D origin.
The room itself is constructed in an interesting way. To give each wall a negative-left, positive-right feel, we build everything as if it were the back wall and rotate it around the Y axis into place. This is a tremendously human simplification: it removes all the special casing that would otherwise be needed if the walls were constructed in coordinate system orientation. When you’re looking at any wall inside-face on, left and right are in the appropriate directions.
Hanging Pictures on the Walls
The real problem isn’t about pictures, but they’ll do for this discussion. They are flat and fit on a wall.
The idea is that when you put a picture on a wall, you’re subject to a set of constraints. First they need to hang on a wall - not the floor or the ceiling - Y is always along the vertical axis of the picture. Second, they can hang on any wall, front, back, left or right in our case. And third, they must be flat on a wall - you can’t go too far into a corner without intersecting a wall - or above the ceiling or below the floor for that matter.
Now, for the sake of freedom of expression, we’ll relax the third rule slightly. If a picture intersects a corner, it will go through it - but only to it’s halfway point. Whichever wall the halfway point of the picture is on, that’s the wall the picture will lay flat against.
With all that background set up, we can now talk about the problem of moving pictures on the walls.
Sliding a Picture on the Walls
We’ll step into interaction now - we can select a picture by touching it, and we move it around the room by dragging it along the wall. When we let go, the picture sticks on the wall where we dragged it.
If we drag it into a corner, it stays on the wall until the picture’s halfway point crosses the edge of the wall it’s on and then it rotates onto the other wall. We don’t ever lift the picture up and put it down - we slide it along the walls.
That may seem crazy… when you want to move a physical picture to another spot on in a physical room, you don’t drag it on the wall. But for our purposes, keeping it on the wall is useful, and pixels don’t leave marks and the pictures don’t require picture hangers.
Sliding a Group of Pictures on the Walls
Now things get harder - they aren’t so clear-cut.
We’ll extend the selection model to easily toggle pictures in and out of the selection. This lets us select one or more pictures by touching each of them. If we want to grab a whole set of pictures at once - touch, touch, touch and we’re set to go.
So then we start to drag one of the selected pictures - what should happen? Clearly, we want the whole set to move together as a group. Drag up and they all move up. Drag down, all down. Left, right - left, right. But what happens when we drag to a corner?
Wrong Turn #1
Working too fast is a great way to find out what you don’t know. And it can lead to interesting results that are just plain incorrect.
I blasted through the code, first separating the picture being dragged directly from the rest of the selected pictures. Then I just had the selected pictures track the relative movement of the dragging picture.
diff3 = draggedNewPosition3 - draggedOldPosition3 rot3 = cornerRotationFromPosition(draggedNewPosition) for selected in selection selected.position3 += diff3 selected.rotation3 = rot3
That worked like a charm until I hit a corner. All of a sudden, every picture in the selection, wherever it was in the middle of moving, rotated in place and unless it’s horizontal midpoint coincided with the picture being dragged, began to move away from the wall. The dragged picture made the appropriate movements and rotations, but the rest of the selection spun and moved in the same direction as the dragged object - not staying on the walls as we wanted it to.
Wrong Turn #2
Clearly, the selected pictures shouldn’t be spinning around their centers, they should be spinning around a common center, that being the center of the picture being dragged. When it spins, they should all spin around the common point, the whole of the selection rotating onto the other wall.
Grouping the selection temporarily in a new 3D object,
diff3 = draggedNewPosition3 - draggedOldPosition3 rot3 = cornerRotationFromPosition(draggedNewPosition) selectedGroup.position3 += diff3 selectedGroup.rotation3 = rot3
Beautiful! The whole group turned at once and what was flat against one wall was now flat along the other! Sort of… Some objects were far off the wall on one side of the dragged picture or the other. And if the selection included picture from multiple walls, everything was messed up.
What was breaking down in both of the wrong turns was the third of our three constraints. The midpoints of the pictures were spinning before they individually hit the corner, unknowingly first and then knowingly. I found two ways to make it fail spectacularly.
Instead of what I’d tried, the selected pictures needed to act like they were being dragged individually, as if there were n drags going on simultaneously.
What Does Synchronous Movement Really Mean?
So we’re finally at the root of the issue. If there are a set of pictures all moving synchronously along the walls of the room, each interacting with corners correctly, then we need a different way to talk about movement than in pure 3D.
What’s actually happening when you think about it, is that the movement is occurring in 2D on the inside of a box. It’s 3D in the sense that you’re looking at 3D renditions of pictures in a 3D environment you can walk through, but the movement of the pictures as defined is all about moving on the surface of the walls.
So, what does 2D movement mean on the walls of our 3D box? Well, that’s straightforward. You can move a picture up, down, left and right. Up and down is easy. That’s just our Y coordinate.
diffY = draggedNewPosition3.y - draggedOldPosition3.y diffVertical = diffY
So it’s easy to know from the dragged movement how far the pictures moved vertically. But horizontally? That’s a little different.
Right and Left Movement
Unfortunately, it isn’t as easy to figure left-right movement as up-down since left-right changes it’s coordinate system depending on which wall you’re on. In our simple rectangular box situation, each wall is different - the back and front walls depend on X, the left and right walls depend on Z, and the coordinates are pairwise-reversed for each.
This is not a pretty situation. How the heck do you even start to figure out the horizontal distance between two points on different walls?
The simple answer: you measure it! Let’s go back to first principles of arithmetic. What is 8–3? It’s 5 of course, everyone knows that. But stop and think; what does 8–3 really mean?
A number n is the magnitude in a consistent frame. Measurement only makes sense if there’s an established frame of reference in which magnitudes can be compared. So
8 - 3 = 5
is just the shorthand that we all got past a long time ago. Really, it’s
(8-0) + (0-3) = 5
When we operate on them, numbers are actually the distance to the origin of the number line.
This perspective is just what we need to figure out our horizontal movement. We need to pick a point inside the box and make it the left-right origin, measure the distances along the walls to both the new and old points, and subtract them to get the horizontal difference.
distNew = horizontalDistanceFromReference(draggedNewPosition3) distOld = horizontalDistanceFromReference(draggedOldPosition3) diffHorizontal = distNew-distOld
To make things easy, we’ll align horizontal measurement with the left and right directions our back wall: left will be negative and right will be positive.
Measuring Distance Around the Box
For no other better reason, we’ll pick the corner between the front and the left face as our origin. Any arbitrary point on the perimeter will do, but the front-left corner is as good as any.
horizontalDistance = if position3.x == -width/2 # we're on the left wall depth/2-position3.z else if position3.z == -depth/2 # we're on the back wall depth + position3.x+width/2 else if position3.x == width/2 # we're on the right wall depth+width + position3.z+depth/2 else if position3.z == depth/2 # we're on the front wall depth+width+depth + width/2-position3.x
Now, I’m not going to go into the detail of what to do for the walls in a non-rectangular room, where walls are angled. Just know that it means using a little trigonometry. Not hard, but it takes away from the discussion a little bit. I also didn’t bother to iterate through an in-order wall array. Again, just extra complication. The takeaway is we walk the walls in order and generate the position.
Moving to a new Wall Position
Since we know how far we the horizontal movement is, now we have to apply it to all the pictures in the selection. This turns out to be a simple reversal of the distance calculation. For each picture,
newHorizontalDistance = oldHorizontalDistance+horizontalDifference if newHorizontalDistance < depth position3.x = -width/2 position3.z = depth/2 - newHorizontalDistance else if newHorizontalDistance < depth+width position3.z = -depth/2 position3.x = (newHorizontalDistance-depth) - width/2 else if newHorizontalDistance < depth+width+depth position3.x = width/2 position3.z = (newHorizontalDistance-depth-width) - depth/2 else if newHorizontalDistance < depth+width+depth+width position3.z = depth/2 position2.x = width/2 - (newHorizontalDistance-depth-width-depth)
It’s not hard, but one needs to be careful of the directions of the axes related to the different walls.
And Getting the Appropriate Wall Rotation
Remember how we built the walls? We used the back wall as a reference and rotated them into place. We can use a calculation just like the one for horizontal distances to get the picture rotations:
rotation.Y = if position3.x == -width/2 # we're on the left wall leftWall.rotation.Y else if position3.z == -depth/2 # we're on the back wall backWall.rotation.Y else if position3.x == width/2 # we're on the right wall rightWall.rotation.Y else if position3.z == depth/2 # we're on the front wall frontWall.rotation.Y
Three Lefts Make a Right
One more thing, just to make sure it’s clear. If we travel the whole way around the box, we end up at the starting place. This logically implies we could travel in either direction, left or right, to reach the same horizontal point on the box. If you’re interested,
perimeter = 2*(width+depth if abs(horizontalDistance) > perimeter/2 horizontalDistance = -sign(horizontalDistance)*(perimeter-abs(horizontalDistance))
This sets the horizontal distance to either left (negative) or right (positive), whichever has the smallest magnitude. It doesn’t matter, but it’s nice to know the transformation.
This also implies that if the amount of the difference is bigger than the perimeter or less than zero, it can be normalized to be between zero and the perimeter. Just to keep everything behaving nicely.
Putting it All Together
We now have all the pieces need to drag a set of selected pictures along the walls of a room to a new position.
diffVertical = draggedNewPosition3.y - draggedOldPosition3.y diffHorizontal = horizontalDistanceFromReference(draggedNewPosition3) - horizontalDistanceFromReference(draggedOldPosition3) for selected in selection selected.position3.y = selected.position3.y+diffVertical distOld = horizontalDistanceFromReference(selected.position3) distNew = distOld+diffHorizontal if distNew < depth selected.position3.x = -width/2 selected.position3.z = depth/2 - distNew selected.rotation.y = leftWall.rotation.y else if distNew < depth+width selected.position3.z = -depth/2 selected.position3.x = (distNew-depth) - width/2 selected.rotation.y = backWall.rotation.y else if distNew < depth+width+depth selected.position3.x = width/2 selected.position3.z = (distNew-depth-width) - depth/2 selected.rotation.y = rightWall.rotation.y else if distNew < depth+width+depth+width selected.position3.z = depth/2 selected.position3.x = width/2 - (distNew-depth-width-depth) selected.rotation.y = frontWall.rotation.y
That’s it. Now, as the selection of pictures are dragged, they will move synchronously, and turn the corners as their midpoints reach them.
Nothing is as easy as it usually looks. I’ve been doing 3D work and programming for a long time and the answer did not just magically appear. It took some thought.
Math is hard. But you can make it a lot harder if you try to whiz through it without looking at it objectively. Sometimes you have to take a step back past learned shortcuts to simplify a problem.
A solution you worked hard for gives you more satisfaction than one that came easily. Maybe I just have a slight masochistic streak to do this kind of work, but I was really glad once everything worked out.