2
\$\begingroup\$

Just for fun, I had been attempting to build a model cube in vanilla JS that can be moved and/or rotated. What I was going for was the simplest possible representation (so, no adjusting for perspective or anything: it just uses the literal x, y and z positions for each vertex).

The result I got is in fact 3-dimensional: rotating about the axes in the plane of the screen reveals the further-back vertices which were originally hidden. But the way it rotates isn't at all what I intended, since instead of spinning in place, it stretches, distorts, and even scales!

I asked a friend what was going on with this, and he said that what was actually happening was that the camera perspective was changing. (As opposed to the cube truly changing shape.) I confirmed that this is correct by superimposing my cube model on a representation of the 3 axes (x, y, z) and using the same rotation algorithm on the axes themselves - sure enough, their movement paralleled that of the cube.

This is the bizarre part, though: I did not try (and don't even know how) to program any kind of 'camera object'! What this cube simulation is supposed to do is 'physically' (or as close as you can get for a virtual object, anyway) move/spin the cube within a fixed coordinate space. Looking back over the code, I don't see any way that anything other than this could be happening, but it is.

I had also received feedback that it isn't possible to accurately represent a 3D object in 3D space, and that to work correctly, it would have to be modeled in 4 dimensions. While I know that this is true in general,* that really doesn't seem like it should apply here.** Especially, since it isn't an actual 3D cube, but instead the projection of a 3D cube onto a 2D plane (the screen), since only the x and y coordinates are used in actually displaying it (z is only there to calculate changes in x and y).

So, what exactly is going on to create the 'moving camera' illusion, and is there a simple fix? I want the cube to rotate in place, and the coordinate system to remain fixed. I would be very interested to learn where, specifically, my version diverges from the expected outcome, and whether or not the version I intended is actually possible to program using only 3 dimensions. I have included the complete code as a Stack Snippet, so you can see the strange results for yourself! (Translation works as intended, only rotation is problematic.)

Comments are included next to the relevant parts of the code to describe the method being used in modeling the rotations (basically, conversion back and forth from Cartesian to polar coordinates).


*The general rule is that you need n + 1 dimensions to observe all possible rotations of an n-dimensional object - e.g. for a line (1D), you have to have a 2D plane to rotate it in; for a square (2D), you have to have a 3D space, etc.

**In every case, there is always an exception to the n + 1 rule. It is possible to rotate an n-dimensional object in only n dimensions within the dimension containing the object. You can rotate a line segment within the line it is a part of; you can rotate a square within the theoretical infinite plane which contains it; it's only when the object leaves the dimension that originally contained it (as in the square being rotated perpendicularly to the original plane) that you need the extra dimension. Since we can't actually leave the 3rd dimension, it seems like any rotation being performed on a real 3D object should fit into this 'special case' that does not require an extra axis to represent.

// References: https://www.w3schools.com/graphics/canvas_drawing.asp and https://www.w3schools.com/jsref/api_canvas.asp

// CANVAS SETUP
var canvas = document.getElementById("_canvas");
var c = canvas.getContext("2d");
c.fillStyle = "rgb(0, 20, 0)";
c.fillRect(0, 0, 256, 256);

// GLOBAL VARIABLES
var CENTER = {x: 128, y: 128, z: 128};
var SIDE_LENGTH = 32;
var translationIncrement = 1;
var rotationIncrement = 1;
var mouseIsDown = false; // Potential Bug - You can cause infinite recursion inside the cube's translate and rotate methods by pressing down a button, then sliding the mouse off of the button before releasing the mouse.  Why would anyone do this, though? :D  (Can be stopped by clicking any of the translation buttons again)
var buttons = {
    translation: [
        {button: document.getElementById("translateX+"), action: function() { mouseIsDown = true; return theCube.translate('x', translationIncrement); }},
        {button: document.getElementById("translateY+"), action: function() { mouseIsDown = true; return theCube.translate('y', translationIncrement); }},
        {button: document.getElementById("translateX-"), action: function() { mouseIsDown = true; return theCube.translate('x', -translationIncrement); }},
        {button: document.getElementById("translateY-"), action: function() { mouseIsDown = true; return theCube.translate('y', -translationIncrement); }}
    ],
    rotation: [
        {button: document.getElementById("rotateX+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', rotationIncrement); }},
        {button: document.getElementById("rotateY+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', rotationIncrement); }},
        {button: document.getElementById("rotateZ+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', rotationIncrement); }},
        {button: document.getElementById("rotateX-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', -rotationIncrement); }},
        {button: document.getElementById("rotateY-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', -rotationIncrement); }},
        {button: document.getElementById("rotateZ-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', -rotationIncrement); }}
    ]
};
for (buttonSet in buttons) {
    for (var btn = 0; btn < buttons[buttonSet].length; btn++) {
        buttons[buttonSet][btn].button.addEventListener('mousedown', buttons[buttonSet][btn].action);
        buttons[buttonSet][btn].button.addEventListener('mouseup', function() { mouseIsDown = false; });
    }
}

// UTILITY FUNCTIONS
var toRadians = function(numberInDegrees) {
    return numberInDegrees * Math.PI / 180;
};
var toDegrees = function(numberInRadians) {
    return numberInRadians * 180 / Math.PI;
};
// Returns the angle (in degrees) representing a point's rotational position relative to the specified axis
// aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
var getAngleAboutAxisForPointRelToCenter = function(aboutAxis, center, coords) {
    // Vertical and horizontal displacements of coords relative to center
    var vrt, hrz;
    if (aboutAxis.toLowerCase() === 'x') {
        vrt = coords.y - center.y;
        hrz = coords.z - center.z;
    }
    else if (aboutAxis.toLowerCase() === 'y') {
        vrt = coords.z - center.z;
        hrz = coords.x - center.x;
    }
    else if (aboutAxis.toLowerCase() === 'z') {
        vrt = coords.y - center.y;
        hrz = coords.x - center.x;
    }
        
    // Avoid division by 0 inside expression to calculate the angle if the point is the same as the center of rotation
    if (!vrt && !hrz) {
        return undefined;
    }
        
    // The intermediate value A will be a value between 0 and 180 degrees.
    // Math.asin returns an angle which is 90 degrees offset from what you'd expect it to be, and also the negative of the expected value, so A is adjusted for this.  (The final absolute value call is to avoid the 0 degree angle being output as -0.)
    var A = Math.abs(-(toDegrees(Math.asin(hrz/(vrt**2 + hrz**2)**0.5)) - 90));
    
    // The vertical axis is inverted on the HTML canvas element!
    if (vrt > 0) { // QIII and QIV - angle is decreasing when it should be increasing.  Fix this by subtracting value from 360.
        return 360 - A;
    } else {
        return A; // QI and QII: No adjustments needed
    }
};
// Returns the differences in a point's vertical and horizontal coordinates after it has been rotated about the specified axis by angle degrees, centered on the specified center coordinates.
// aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
// initialAngle parameter is optional; it will be calculated inside this function only if it hasn't already been provided as input, to reduce unnecessary computations.
var getCoordChangesForPointRotatedAboutAxisRelToCenter = function(aboutAxis, center, coords, angle, initialAngle) {
    if (initialAngle === undefined) {
        initialAngle = getAngleAboutAxisForPointRelToCenter(aboutAxis, center, coords);
    }
    // Vertical and horizontal displacements of coords relative to center
    var vrt, hrz, vName, hName;
    if (aboutAxis.toLowerCase() === 'x') {
        vrt = coords.y - center.y;
        hrz = coords.z - center.z;
        vName = 'y', hName = 'z';
    }
    else if (aboutAxis.toLowerCase() === 'y') {
        vrt = coords.z - center.z;
        hrz = coords.x - center.x;
        vName = 'z', hName = 'x';
    }
    else if (aboutAxis.toLowerCase() === 'z') {
        vrt = coords.y - center.y;
        hrz = coords.x - center.x;
        vName = 'y', hName = 'x';
    }
            
    // Length of the line segment connecting the point to the center of rotation
    var distToCenter = (vrt**2 + hrz**2)**0.5;
    
    // The differences in horizontal and vertical positions
    diffs = {};
    // When rotating about x or z axis, the vertical coordinate will be y, which is inverted on the HTML canvas.
    var invertMultiplier = ((-1)**(aboutAxis.toLowerCase() === 'z' || aboutAxis.toLowerCase() === 'x'));
    diffs[hName] = distToCenter*(Math.cos(toRadians(angle + initialAngle)) - Math.cos(toRadians(initialAngle)));
    diffs[vName] = distToCenter*(Math.sin(toRadians(angle + initialAngle)) - Math.sin(toRadians(initialAngle)))*invertMultiplier;
    return diffs;
};

// CUBE MODEL PROTOTYPE
/* A hollow cube consists of |8 vertices| and |the set of all line segments, connecting those vertices, which are not diagonals| */
/** The argument for the 'center' parameter should be an object of the form {x: n1, y: n2, z: n3} where n1, n2 and n3 are Numbers; sideLength should be a Number */
var HollowCube = function(center, sideLength) {
    // Store references to the cube's dimensions and center position
    this.center = {x: center.x, y: center.y, z: center.z}; // Make a deep copy, not a shallow copy here
    this.sideLength = sideLength;
    
    // Format of each vertex is {x, y, z}
    this.vertices = [{x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
                     {x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
                     {x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
                     {x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
                     {x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength},
                     {x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
                     {x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
                     {x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength}];
    this.initialVertexAngles = [];
    // Initial angular positions of each vertex (represented as angles of rotation about the 3 axes)
    for (var i = 0; i < this.vertices.length; i++) {
        this.initialVertexAngles[i] = {
            x: getAngleAboutAxisForPointRelToCenter('x', this.center, this.vertices[i]),
            y: getAngleAboutAxisForPointRelToCenter('y', this.center, this.vertices[i]),
            z: getAngleAboutAxisForPointRelToCenter('z', this.center, this.vertices[i])
        };
    }
    // Cumulative angles of rotation about each axis, representing the cube's current position
    this.currentRotationsAboutAxes = {x: 0, y: 0, z: 0};
    // Amounts (Cartesian coordinates) by which each vertex is currently displaced from its initial position
    this.vertexDisplacementsFromInitial = [{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0},
                                {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}];
                                
    // Format of each edge is [vertex1, vertex2] where vertex1 and vertex2 are the indices of vertices in this.vertices - Note that each line is only specified once (i.e. all vertex pairs which would be duplicates have been omitted)
    this.edgesConnectingVertices = [[0, 1], [0, 3], [0, 4], [1, 2], [1, 5], [2, 3], [2, 6], [3, 7], [4, 5], [4, 7], [5, 6], [6, 7]];
    this.edgeColors = ["rgb(255, 0, 0)", "rgb(0, 0, 255)", "rgb(0, 255, 255)", "rgb(255, 255, 0)", "rgb(255, 0, 255)", "rgb(0, 255, 0)",
                       "rgb(0, 255, 255)", "rgb(255, 0, 255)", "rgb(0, 255, 0)", "rgb(255, 255, 0)", "rgb(0, 0, 255)", "rgb(255, 255, 0)"];
    
    // Used in setting a delay between recursive calls of the translation and rotation functions, when one of the buttons is pressed down
    this.onCooldown = false;
};
/** The argument for the 'alongAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase.  The argument for the 'amountInPixels' parameter should be of type Number */
HollowCube.prototype.translate = function(alongAxis, amountInPixels, canvasContext = c) {
    this.center[alongAxis.toLowerCase()] += amountInPixels;
    for (var v = 0; v < this.vertices.length; v++) {
        this.vertices[v][alongAxis.toLowerCase()] += amountInPixels;
    }
    this.display(canvasContext);
    // Keep on moving the cube until the mouse is released; don't make the user have to click for every single increment
    if (mouseIsDown && !this.onCooldown) {
        this.onCooldown = true;
        // Inside setTimeout, 'this' no longer refers to the HollowCube object, so it is necessary to set up a new reference to the original object to use inside setTimeout.  It is important that the statement that resets the cooldown timer go INSIDE the inner wrapped function (not outside it!) in order for the timing to function as intended.
        var temp = function(obj) { return function() { obj.onCooldown = false; obj.translate(alongAxis, amountInPixels, canvasContext); }};
        var temp2 = this;
        setTimeout(temp(temp2), 10); // BARF - this is an ugly kludge :(
    }
};
/** The argument for the 'aboutAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase.  The argument for the 'amountInDegrees' parameter should be of type Number */
HollowCube.prototype.rotateAboutOwnCenter = function(aboutAxis, amountInDegrees, canvasContext = c) {
    this.currentRotationsAboutAxes[aboutAxis.toLowerCase()] += amountInDegrees;
    
    // Reset this before calculating the current displacements, as they aren't cumulative (displacements are recalculated every time the cube is rotated, for the new resultant angle
    this.vertexDisplacementsFromInitial = [{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0},
                                {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}];
                                
    for (var i = 0; i < this.vertices.length; i++) {
        for (var axs in this.currentRotationsAboutAxes) {
            var rotn = getCoordChangesForPointRotatedAboutAxisRelToCenter(axs, this.center, this.vertices[i], this.currentRotationsAboutAxes[axs], this.initialVertexAngles[i][axs]);
            for (var cmpnt in rotn) {
                this.vertexDisplacementsFromInitial[i][cmpnt] += rotn[cmpnt];
            }
        }
    }
    
    this.display(canvasContext);
    // Keep on rotating the cube until the mouse is released; don't make the user have to click for every single increment
    if (mouseIsDown && !this.onCooldown) {
        this.onCooldown = true;
        var temp = function(obj) { return function() { obj.onCooldown = false; obj.rotateAboutOwnCenter(aboutAxis, amountInDegrees, canvasContext); }};
        var temp2 = this;
        setTimeout(temp(temp2), 10);
    }
};
/** Since the screen is 2-dimensional, the depth information (z-axis) cannot be used directly and is only perceived via its effects in calculating the new x and y coordinates when the cube is rotated. */
HollowCube.prototype.display = function(canvasContext) {
    // Redraw background to avoid the cube leaving trails across the canvas
    c.fillStyle = "rgb(0, 20, 0)";
    c.fillRect(0, 0, 256, 256);
    c.lineWidth = 2;
    for (var segment = 0; segment < this.edgesConnectingVertices.length; segment++) {
        canvasContext.beginPath();
        canvasContext.moveTo(this.vertices[this.edgesConnectingVertices[segment][0]].x + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][0]].x, this.vertices[this.edgesConnectingVertices[segment][0]].y + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][0]].y);
        canvasContext.lineTo(this.vertices[this.edgesConnectingVertices[segment][1]].x + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][1]].x, this.vertices[this.edgesConnectingVertices[segment][1]].y + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][1]].y);
        canvasContext.strokeStyle = this.edgeColors[segment];
        canvasContext.stroke();
    }
};

// INSTANTIATION
var theCube = new HollowCube(CENTER, SIDE_LENGTH);
theCube.display(c);
body {
    background-color: lightgreen;
}
div {
    background-color: green;
    width: 80%;
    margin-left: 10%;
    padding-top: 2%;
    padding-bottom: 2%;
}
button {
    background-color: lightgreen;
    color: darkgreen;
    font-weight: bold;
    height: 25pt;
}
<body>
   <div>
     <p align="center">
          <canvas id="_canvas" width="256" height="256"></canvas>
     </p>
     <p align="center">
          <button type="button" id="translateX+">Move Along Positive X</button>
          <button type="button" id="translateY+">Move Along Positive Y</button><br><br>
          <button type="button" id="translateX-">Move Along Negative X</button>
          <button type="button" id="translateY-">Move Along Negative Y</button><br><br><br>
          <button type="button" id="rotateX+">Rotate About X (+)</button>
          <button type="button" id="rotateY+">Rotate About Y (+)</button>
          <button type="button" id="rotateZ+">Rotate About Z (+)</button><br><br>
          <button type="button" id="rotateX-">Rotate About X (-)</button>
          <button type="button" id="rotateY-">Rotate About Y (-)</button>
          <button type="button" id="rotateZ-">Rotate About Z (-)</button>
     </p>
   <div>
</body>

P.S. This question is not a duplicate of the one here: 2D camera perspective projection from 3D coordinates -- HOW? because in that question, OP wanted to design a perspective projection, while I'm going for what I believe is called an 'orthographic projection' - one that does not use perspective. It is also not a duplicate of this one: Rotate object around fixed axis, because they're using 3D rendering software (not vanilla JS), and also quaternions (I'm specifically asking whether it's possible without modeling the cube in 4 dimensions).


EDIT (a reply to the accepted answer):

@Theraot, your version is everything I was going for in half the number of lines!

  1. About your observation "I understand that you were trying to not move the vertex" - initially, I had tried a version that DID actually change the vertex coordinates for rotation as well as for translation. It also used the length of the internal diagonal from the cube's center to any one of the vertices in place of the var distToCenter = (vrt**2 + hrz**2)**0.5; equation. The problem was that somehow, a vertical asymptote was appearing at the 90 degree point: as the cube was rotated to this point, it started stretching infinitely instead of rotating past that point. Your version avoids that problem, so is actually more like the effect I originally wanted than was my 'patched' version with the vertexDisplacementsFromInitial .

  2. Inside the (much streamlined) rotateAboutOwnCenter method, you're now assigning the vertices' positions to the rotation's components, instead of incrementing them. I see that you've added the extra term + center[(h/v)Name] inside the function that performs the rotations, to be equivalent to the end result of adding the original vertex positions to the displacements in my version. What I'm still confused about is how yours works (and it does work perfectly) without having any kind of 'accumulator' to store the partial vs. total changes in each coordinate (x, y and z). In the original, I was calculating delta(x) = xy + xz, etc.: each coordinate's total change was the sum of two partial components from rotations about two different axes. With assignment instead of incrementation inside the for loop, shouldn't the second partial component just overwrite the first? Again, I know yours is correct, but just don't see how.

  3. Now you have me really curious about how you would have fixed the button kludge! I'm also interested in seeing how the rotation matrix would replace my implementation which used individual, separate calculations for X, Y and Z axis rotations. I'm familiar with the concept of matrices from the one matrix algebra class I took, but it definitely isn't the way that naturally occurs to me to go about solving problems. A side-by-side comparison would be really neat, to see how the matrix version performs the calculations 'in parallel' instead of 'in series' (at least, I'd assume that it would). If I post a new question on the code review site, asking about how the cube model could be improved in those two aspects specifically, would you be interested in answering?

\$\endgroup\$
1
  • \$\begingroup\$ You'll have to select "Full Page" when you run the snippet, because unfortunately the buttons under the canvas don't fit within the narrow preview window. \$\endgroup\$ Commented Jul 16, 2023 at 21:34

1 Answer 1

1
\$\begingroup\$

On the camera illusion

The camera projection (regardless of it being perspective, orthogonal, or something else) is a transformation or series of transformations.

When you move your cube, you move its vertices. This can be understood as applying a transformation to the vertices. And, you are, of course, having a 2D view of it. That is a projection.

So, there is a camera projection that is equivalent to - most※ - of what you are doing.

The illusion is further solidified by the fact that there aren't other objects moving independently. So we don't register it as an cube moving relative to something else.

Something else that might or might not contribute to the illusion is the fact that you do not seem to define the rotation axis in the local space of the cube.

※: With your current code it is possible to distort the cube in ways that an orthogonal camera would not.


There are, of course, no actual cameras in virtual video game worlds. It is all an illusion anyway. It just happens that this illusion is packaged in a re-utilizable component that we call "camera".

Such re-utilizable component would abstract the cumulative transformation you would apply to the vertices to account for the camera position, rotation, and so on. And thus, allowing you to keep the positions of the vertices fixed... Or move them only when they need to be moved relative to each other.

At the end, a frame must be rendered, and thus the transformation must be computed... And there more ways to get there than game engines. Literally.

Of course, hardware and software architecture might make the solutions more or less converge.


On 4D

No, you don't need to represent your cube with four coordinates per vertex.

What you describe about rotations makes no sense to me. For example, if you have a 1D line, sure you can rotate it in 2D, but you can also rotate it in 3D in ways that you cannot rotate it in 2D, and so on... There is no "observe all possible rotations". For rotation you only needs as many dimensions as the space you want to work with, and - as you point out - you are not trying to rotate beyond three dimensions.

Instead the push to work with 4D is something we do so we can have translation along with the other 3D affine transformation represented in a single transformation matrix.

That is, we can use a 3 by 3 matrix to represent scaling, rotating and skewing in 3D. But we need to augment it to add 3D translation in the same matrix (3D translation is a linear transformation in 4D, but only an affine transformation in 3D). But of course, for the vector-matrix multiplication to work, we would also have to augment the vectors with an extra component.

Working with transformation matrices is convenient because we can compose transformations by multiplying their matrices, and also because hardware is very fast at doing matrix multiplications.

By the way, the approach in which we have settled on for doing a perspective projection requires a 4 by 4 matrix. Which I know is not intuitive given that we are going to drop to two dimensions... But we need those two dimensions to be affected by the position on the third dimension in a non linear way, and we are encoding that in the fourth dimension. More precisely we do it by working with homogeneous coordinates. We need the two dimension we are going to see. The third dimension for z-ordering. And the the fourth dimension is the non-zero scalar that makes them homogeneous, which is encoding foreshortening.

So, if you use 4 by 4 matrices for all the transformation, you can have a whole stack of transformations (from positioning and scaling an object, to positioning a camera, to doing the perspective transformation) in a single matrix.

With all that said, I want to reiterate that four dimensions are not a requirement for 3D rotation, scaling or skewing.


Addendum on Quaternions

Quaternions are 4D numbers in the same sense that complex numbers are 2D numbers. Due to the way they are constructed, making 3D numbers the same way would not preserve all the properties of divisions.

As it turns out, unit quaternions (that is quaternions of length 1) are a convenient way to represent rotations and orientations. They can also be composed in a similar fashion as transformation matrices, but take less memory and less operations.

However, aside from optimizations, it is not necessary to use quaternions for 3D rotations. Although they are helpful in many cases.


Fixing the rotation

I understand that you were trying to not move the vertex. However, that made it harder for me. So I went on removing all the "current" and "initial" and "DisplacementsFromInitial", I don't care if the y axis is flipped, you can fix that... and replaced what I didn't understand with things I understand, until I got it working.

    // References: https://www.w3schools.com/graphics/canvas_drawing.asp and https://www.w3schools.com/jsref/api_canvas.asp

    // CANVAS SETUP
    var canvas = document.getElementById("_canvas");
    var c = canvas.getContext("2d");
    c.fillStyle = "rgb(0, 20, 0)";
    c.fillRect(0, 0, 256, 256);

    // GLOBAL VARIABLES
    var CENTER = {x: 128, y: 128, z: 128};
    var SIDE_LENGTH = 32;
    var translationIncrement = 1;
    var rotationIncrement = 1;
    var mouseIsDown = false; // Potential Bug - You can cause infinite recursion inside the cube's translate and rotate methods by pressing down a button, then sliding the mouse off of the button before releasing the mouse.  Why would anyone do this, though? :D  (Can be stopped by clicking any of the translation buttons again)
    var buttons = {
        translation: [
            {button: document.getElementById("translateX+"), action: function() { mouseIsDown = true; return theCube.translate('x', translationIncrement); }},
            {button: document.getElementById("translateY+"), action: function() { mouseIsDown = true; return theCube.translate('y', translationIncrement); }},
            {button: document.getElementById("translateX-"), action: function() { mouseIsDown = true; return theCube.translate('x', -translationIncrement); }},
            {button: document.getElementById("translateY-"), action: function() { mouseIsDown = true; return theCube.translate('y', -translationIncrement); }}
        ],
        rotation: [
            {button: document.getElementById("rotateX+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', rotationIncrement); }},
            {button: document.getElementById("rotateY+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', rotationIncrement); }},
            {button: document.getElementById("rotateZ+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', rotationIncrement); }},
            {button: document.getElementById("rotateX-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', -rotationIncrement); }},
            {button: document.getElementById("rotateY-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', -rotationIncrement); }},
            {button: document.getElementById("rotateZ-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', -rotationIncrement); }}
        ]
    };
    for (buttonSet in buttons) {
        for (var btn = 0; btn < buttons[buttonSet].length; btn++) {
            buttons[buttonSet][btn].button.addEventListener('mousedown', buttons[buttonSet][btn].action);
            buttons[buttonSet][btn].button.addEventListener('mouseup', function() { mouseIsDown = false; });
        }
    }

    // UTILITY FUNCTIONS
    var toRadians = function(numberInDegrees) {
        return numberInDegrees * Math.PI / 180;
    };
    var toDegrees = function(numberInRadians) {
        return numberInRadians * 180 / Math.PI;
    };
    // Returns the angle (in degrees) representing a point's rotational position relative to the specified axis
    // aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
    var getAngleAboutAxisForPointRelToCenter = function(aboutAxis, center, coords) {
        // Vertical and horizontal displacements of coords relative to center
        var vrt, hrz;
        if (aboutAxis.toLowerCase() === 'x') {
            vrt = coords.y - center.y;
            hrz = coords.z - center.z;
        }
        else if (aboutAxis.toLowerCase() === 'y') {
            vrt = coords.z - center.z;
            hrz = coords.x - center.x;
        }
        else if (aboutAxis.toLowerCase() === 'z') {
            vrt = coords.y - center.y;
            hrz = coords.x - center.x;
        }

        return toDegrees(Math.atan2(vrt, hrz));
    };
    // Returns the point's vertical and horizontal coordinates after it has been rotated about the specified axis by angle degrees.
    // aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
    // angle parameter is the desired rotation.
    var getCoordChangesForPointRotatedAboutAxisRelToCenter = function(aboutAxis, center, coords, angle) {
        var currentAngle = getAngleAboutAxisForPointRelToCenter(aboutAxis, center, coords);
        // Vertical and horizontal displacements of coords relative to center
        var vrt, hrz, vName, hName;
        if (aboutAxis.toLowerCase() === 'x') {
            vrt = coords.y - center.y;
            hrz = coords.z - center.z;
            vName = 'y', hName = 'z';
        }
        else if (aboutAxis.toLowerCase() === 'y') {
            vrt = coords.z - center.z;
            hrz = coords.x - center.x;
            vName = 'z', hName = 'x';
        }
        else if (aboutAxis.toLowerCase() === 'z') {
            vrt = coords.y - center.y;
            hrz = coords.x - center.x;
            vName = 'y', hName = 'x';
        }

        // Length of the line segment connecting the point to the center of rotation
        var distToCenter = (vrt**2 + hrz**2)**0.5;

        result = {};
        result[hName] = distToCenter*(Math.cos(toRadians(angle + currentAngle))) + center[hName];
        result[vName] = distToCenter*(Math.sin(toRadians(angle + currentAngle))) + center[vName];
        return result;
    };

    // CUBE MODEL PROTOTYPE
    /* A hollow cube consists of |8 vertices| and |the set of all line segments, connecting those vertices, which are not diagonals| */
    /** The argument for the 'center' parameter should be an object of the form {x: n1, y: n2, z: n3} where n1, n2 and n3 are Numbers; sideLength should be a Number */
    var HollowCube = function(center, sideLength) {
        // Store references to the cube's dimensions and center position
        this.center = {x: center.x, y: center.y, z: center.z}; // Make a deep copy, not a shallow copy here
        this.sideLength = sideLength;

        // Format of each vertex is {x, y, z}
        this.vertices = [{x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
                         {x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
                         {x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
                         {x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
                         {x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength},
                         {x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
                         {x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
                         {x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength}];

        // Format of each edge is [vertex1, vertex2] where vertex1 and vertex2 are the indices of vertices in this.vertices - Note that each line is only specified once (i.e. all vertex pairs which would be duplicates have been omitted)
        this.edgesConnectingVertices = [[0, 1], [0, 3], [0, 4], [1, 2], [1, 5], [2, 3], [2, 6], [3, 7], [4, 5], [4, 7], [5, 6], [6, 7]];
        this.edgeColors = ["rgb(255, 0, 0)", "rgb(0, 0, 255)", "rgb(0, 255, 255)", "rgb(255, 255, 0)", "rgb(255, 0, 255)", "rgb(0, 255, 0)",
                           "rgb(0, 255, 255)", "rgb(255, 0, 255)", "rgb(0, 255, 0)", "rgb(255, 255, 0)", "rgb(0, 0, 255)", "rgb(255, 255, 0)"];

        // Used in setting a delay between recursive calls of the translation and rotation functions, when one of the buttons is pressed down
        this.onCooldown = false;
    };
    /** The argument for the 'alongAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase.  The argument for the 'amountInPixels' parameter should be of type Number */
    HollowCube.prototype.translate = function(alongAxis, amountInPixels, canvasContext = c) {
        this.center[alongAxis.toLowerCase()] += amountInPixels;
        for (var v = 0; v < this.vertices.length; v++) {
            this.vertices[v][alongAxis.toLowerCase()] += amountInPixels;
        }
        this.display(canvasContext);
        // Keep on moving the cube until the mouse is released; don't make the user have to click for every single increment
        if (mouseIsDown && !this.onCooldown) {
            this.onCooldown = true;
            // Inside setTimeout, 'this' no longer refers to the HollowCube object, so it is necessary to set up a new reference to the original object to use inside setTimeout.  It is important that the statement that resets the cooldown timer go INSIDE the inner wrapped function (not outside it!) in order for the timing to function as intended.
            var temp = function(obj) { return function() { obj.onCooldown = false; obj.translate(alongAxis, amountInPixels, canvasContext); }};
            var temp2 = this;
            setTimeout(temp(temp2), 10); // BARF - this is an ugly kludge :(
        }
    };
    /** The argument for the 'aboutAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase.  The argument for the 'amountInDegrees' parameter should be of type Number */
    HollowCube.prototype.rotateAboutOwnCenter = function(aboutAxis, amountInDegrees, canvasContext = c) {
        for (var i = 0; i < this.vertices.length; i++) {
            var result = getCoordChangesForPointRotatedAboutAxisRelToCenter(aboutAxis, this.center, this.vertices[i], amountInDegrees);
            for (var component_index in result) {
                this.vertices[i][component_index] = result[component_index];
            }
        }

        this.display(canvasContext);
        // Keep on rotating the cube until the mouse is released; don't make the user have to click for every single increment
        if (mouseIsDown && !this.onCooldown) {
            this.onCooldown = true;
            var temp = function(obj) { return function() { obj.onCooldown = false; obj.rotateAboutOwnCenter(aboutAxis, amountInDegrees, canvasContext); }};
            var temp2 = this;
            setTimeout(temp(temp2), 10);
        }
    };
    /** Since the screen is 2-dimensional, the depth information (z-axis) cannot be used directly and is only perceived via its effects in calculating the new x and y coordinates when the cube is rotated. */
    HollowCube.prototype.display = function(canvasContext) {
        // Redraw background to avoid the cube leaving trails across the canvas
        c.fillStyle = "rgb(0, 20, 0)";
        c.fillRect(0, 0, 256, 256);
        c.lineWidth = 2;
        for (var segment = 0; segment < this.edgesConnectingVertices.length; segment++) {
            canvasContext.beginPath();
            canvasContext.moveTo(this.vertices[this.edgesConnectingVertices[segment][0]].x, this.vertices[this.edgesConnectingVertices[segment][0]].y);
            canvasContext.lineTo(this.vertices[this.edgesConnectingVertices[segment][1]].x, this.vertices[this.edgesConnectingVertices[segment][1]].y);
            canvasContext.strokeStyle = this.edgeColors[segment];
            canvasContext.stroke();
        }
    };

    // INSTANTIATION
    var theCube = new HollowCube(CENTER, SIDE_LENGTH);
    theCube.display(c);
body {
    background-color: lightgreen;
}
div {
    background-color: green;
    width: 80%;
    margin-left: 10%;
    padding-top: 2%;
    padding-bottom: 2%;
}
button {
    background-color: lightgreen;
    color: darkgreen;
    font-weight: bold;
    height: 25pt;
}
<body>
   <div>
     <p align="center">
          <canvas id="_canvas" width="256" height="256"></canvas>
     </p>
     <p align="center">
          <button type="button" id="translateX+">Move Along Positive X</button>
          <button type="button" id="translateY+">Move Along Positive Y</button><br><br>
          <button type="button" id="translateX-">Move Along Negative X</button>
          <button type="button" id="translateY-">Move Along Negative Y</button><br><br><br>
          <button type="button" id="rotateX+">Rotate About X (+)</button>
          <button type="button" id="rotateY+">Rotate About Y (+)</button>
          <button type="button" id="rotateZ+">Rotate About Z (+)</button><br><br>
          <button type="button" id="rotateX-">Rotate About X (-)</button>
          <button type="button" id="rotateY-">Rotate About Y (-)</button>
          <button type="button" id="rotateZ-">Rotate About Z (-)</button>
     </p>
   <div>
</body>

I was really tempted to start over (fix the button kludge, work in radians, etc). That might have resulted in something easier to understand. Had I followed that route I would have implemented this with rotation matrices. But perhaps having something at least similar to what you had is more helpful for you.


I'll expand the answer by going over the differences:

First the code for getting the angle:

    // Avoid division by 0 inside expression to calculate the angle if the point is the same as the center of rotation
    if (!vrt && !hrz) {
        return undefined;
    }
        
    // The intermediate value A will be a value between 0 and 180 degrees.
    // Math.asin returns an angle which is 90 degrees offset from what you'd expect it to be, and also the negative of the expected value, so A is adjusted for this.  (The final absolute value call is to avoid the 0 degree angle being output as -0.)
    var A = Math.abs(-(toDegrees(Math.asin(hrz/(vrt**2 + hrz**2)**0.5)) - 90));
    
    // The vertical axis is inverted on the HTML canvas element!
    if (vrt > 0) { // QIII and QIV - angle is decreasing when it should be increasing.  Fix this by subtracting value from 360.
        return 360 - A;
    } else {
        return A; // QI and QII: No adjustments needed
    }

I don't to want to think about it. This is mine:

    return toDegrees(Math.atan2(vrt, hrz));

Second, since I removed "initial" et.al. are gone, the function getCoordChangesForPointRotatedAboutAxisRelToCenter rotates. It returns the rotated vector. Similarly rotateAboutOwnCenter is much more straight forward.

Third, the constructor for HollowCube is simpler because there is less to initialize.

And finally in display, vertexDisplacementsFromInitial is gone.


Addendum on how rotation works

It is a conversion to polar coordinates. By using Pythagoras and getAngleAboutAxisForPointRelToCenter, we get the coordinates of the vertex expressed as distance to the center, and an angle around the rotation axis (passing through the center). Then we modify the angle, and convert back to Cartesians.


Addendum on fixing the button kludge

If I were re-implementing this code form nothing, I'd start by using requestAnimationFrame as game loop.

The code would be an state machine, with an idle state where it does nothing... And pressing each button changes the state according to the button function. Releasing the button changes the state back to idle.

Being this JavaScript, I would literally set a function to an state variable (and "idle" can be an empty function saving me a check).

Then requestAnimationFrame can call the state function each loop. I might also pass the elapsed time so I can work with speeds inside the functions for the different states.


Addendum on using transformation matrices

The rotation matrix only requires to know the angle, and it uses sin and cos. You would make a different one for each main axis.

However, the rotation matrix rotates around the origin. Since you want to rotate around an arbitrary center point, we first translate to make the center of rotation the origin. Then we do the rotation. And then do the inverse of the translation we did.

If we worked with an augmented 3 by 3 matrix (i.e. a rectangular 3 by 4 matrix) or with 4 by 4 matrices, then we could compose the translation, rotation, and inverse translation in a single matrix.

You can then opt to apply the changes, which means to do a vector-matrix multiplication to get the new position of the vertices and update them.

Alternatively, you can opt to accumulate the transformations. That is, you start with an identity matrix, and keep composing translation and rotation matrices according to the operations the user makes, and only do the vector-matrix multiplication for display. And that accumulated transformation you keep, is your camera transformation (well, arguably it has both model and view matrices, see The view matrix finally explained.


The vector-matrix multiplication can be understood as a series of dot products (so it boils down to multiplying and adding).

Be aware that for the vector-matrix multiplication with a 4 by 4 matrix you would need to also augment the vector. Since we want the vertex to be translated, we augment them with a 1.0 in the fourth dimension (that 1.0 gets multiplied with the translation stored in the matrix, and added to the other components). And after the transformation we don't need the fourth component anymore, so we can discard it, getting back to three dimension. Unless it is a perspective transformation, in which case you first divide by whatever you got in the fourth component, so it is back to 1.0 before you discard it.


The video series Triangles and Pixels might help you understand how transformation matrices are used (for matrices you want in particular the second video, but I still recommend the whole playlist). If you need more material, I recommend 3Blue1Brown videos on Linear Algebra.

And given that you are working with JavaScript, you might find Matrix math for the web useful for the code.

\$\endgroup\$
5
  • \$\begingroup\$ "For rotation you only needs as many dimensions as the space you want to work with" ... Not strictly true. If it were, there would be no need for quaternions. \$\endgroup\$
    – Basic
    Commented Jul 18, 2023 at 13:32
  • \$\begingroup\$ @Basic There is no need for quaternions. Quaternions are useful. \$\endgroup\$
    – Theraot
    Commented Jul 18, 2023 at 13:34
  • \$\begingroup\$ Wow, thank you for your time in writing such a detailed answer! You have cleared up the confusion I had about how quaternions related to performing rotations in 3D space. I like how much more streamlined the improved code is in addition to eliminating the distortion problem, and will have to look up the documentation for that atan2 function you used - it seems to take the place of all the calculations I had had mapping the angle returned by asin from (-180, 180) to (0, 360). \$\endgroup\$ Commented Jul 18, 2023 at 16:46
  • \$\begingroup\$ I would be very interested to see how you would have fixed the buttons and implemented the rotation matrix! If I ask about those optimizations specifically on the Code Review SE site, would you be interested in answering there? I wouldn't want to turn this into the notorious 'moving goalposts' question by asking a follow-up on this site, but asking about optimizations such as those you suggested seems like it would be on topic for the other site. \$\endgroup\$ Commented Jul 18, 2023 at 16:52
  • \$\begingroup\$ @Theraot Somehow, I didn't see the notification when you expanded upon your answer, so I'm just now reading this today. That was very interesting to read, and I wish there were a (legitimate) way of upvoting twice! Thanks as well for all the resources you have linked on the topic :) \$\endgroup\$ Commented Mar 15 at 18:01

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .