Last active
October 26, 2018 15:25
-
-
Save jdmichaud/e5575453debb48980c9ef92e934d7f0e to your computer and use it in GitHub Desktop.
CSS 3d rotation of a cube
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<title>Cube</title> | |
<!-- Trackball is used to test the rotation with the mouse, but you can rotate --> | |
<!-- the cube from the command line this way: --> | |
<!-- > rotateCube('cube1', [1, -1, 0], -Math.PI / 5); --> | |
<script src="trackball.js"></script> | |
<style> | |
* { box-sizing: border-box; } | |
body { | |
font-family: sans-serif; | |
padding: 100px; | |
background-color: black; | |
} | |
/* The scene contains the cube */ | |
/* Comment perspective for an orthographic projection */ | |
.scene { | |
width: 200px; | |
height: 200px; | |
margin: 0px; | |
padding: 0px; | |
/*perspective: 400px;*/ | |
} | |
/* The cube will contains the face. It will be rotated using the trandform property */ | |
.cube { | |
width: 100%; | |
height: 100%; | |
position: relative; | |
transform-style: preserve-3d; | |
} | |
/* Each face is a simple div */ | |
.cube__face { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
border: 2px solid black; | |
outline: 1px solid white; | |
text-align: center; | |
user-select: none; | |
-moz-user-select: none; | |
} | |
.cube__face--front { background: rgba(0, 0, 0, 100%); } | |
.cube__face--right { background: rgba(0, 0, 0, 100%); } | |
.cube__face--back { background: rgba(0, 0, 0, 100%); } | |
.cube__face--left { background: rgba(0, 0, 0, 100%); } | |
.cube__face--top { background: rgba(0, 0, 0, 100%); } | |
.cube__face--bottom { background: rgba(0, 0, 0, 100%); } | |
/* Apply a constant rotation to each div to make a cube */ | |
/* The Z axis is relative to the div so translateZ always works whatever the div orientation */ | |
.cube__face--front { transform: rotateY( 0deg) translateZ(100px); } | |
.cube__face--right { transform: rotateY( 90deg) translateZ(100px); } | |
.cube__face--back { transform: rotateY(180deg) translateZ(100px); } | |
.cube__face--left { transform: rotateY(-90deg) translateZ(100px); } | |
.cube__face--top { transform: rotateX( 90deg) translateZ(100px); } | |
.cube__face--bottom { transform: rotateX(-90deg) translateZ(100px); } | |
.cube__face span { | |
display: block; | |
position: relative; | |
top: 50%; | |
transform: translateY(-50%); | |
font-size: 90px; | |
font-weight: bold; | |
color: white; | |
text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; | |
} | |
</style> | |
<script> | |
// Apply the CSS 3D rotation from the axis and the angle in radian | |
function rotateCube(id, axis, angle) { | |
const transform = `rotate3d(${axis[0]}, ${axis[1]}, ${axis[2]}, ${angle * 180 / Math.PI}deg)`; | |
document.getElementById(id).style.transform = transform; | |
} | |
window.onload = () => track('scene1', 'cube1'); | |
</script> | |
<!-- Here are the set of divs that represents the cube --> | |
<div id="scene1" class="scene"> | |
<div id="cube1" class="cube"> | |
<div class="cube__face cube__face--front"><span>A</span></div> | |
<div class="cube__face cube__face--back"><span>P</span></div> | |
<div class="cube__face cube__face--right"><span>R</span></div> | |
<div class="cube__face cube__face--left"><span>L</span></div> | |
<div class="cube__face cube__face--top"><span>S</span></div> | |
<div class="cube__face cube__face--bottom"><span>I</span></div> | |
</div> | |
</div> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function loadScript(path) { | |
var script = document.createElement('script'); | |
script.type = 'text/javascript'; | |
script.setAttribute('async', false); | |
script.src = path; | |
document.head.appendChild(script); | |
} | |
function normalize(v) { | |
return math.divide(v, math.norm(v)); | |
} | |
function computeTrackball(ballCenter, radius, point) { | |
const radius_squared = radius * radius; | |
const vector_from_center = sub(point.slice(0, 2), ballCenter.slice(0, 2)); | |
const norm_squared = dot(vector_from_center, vector_from_center); | |
let z = 0; | |
if (norm_squared < radius_squared) { | |
// On the sphere | |
z = Math.sqrt(radius_squared - norm_squared); | |
} | |
// if point outside of sphere, the point is on the plane z = 0 | |
return [point[0], point[1], z]; | |
} | |
// Returns the rotation axis and its angle from two vectors | |
function computeRotation(center, previous, current) { | |
previous = sub(previous, center); | |
current = sub(current, center); | |
const axis = cross(current, previous); | |
const angle = Math.acos(dot(normalize(current), normalize(previous))); | |
return { axis, angle }; | |
} | |
function projectPointOnTrackball(center, radius, previous, current) { | |
// Compute the current position on the trackball we are point it to | |
const previousSphere = computeTrackball(center, radius, [...previous, 0]); | |
const currentSphere = computeTrackball(center, radius, [...current, 0]); | |
return [previousSphere, currentSphere]; | |
} | |
// Find the basis of plane when given a normal unit vector of that plane | |
// and an origin | |
function findPlaneBasis(origin, normal) { | |
// First, find two points on the plane for which the axis is the normal vector | |
let v1, v2; | |
if (normal[1] === 0 && normal[2] === 0) { | |
if (normal[0] < 0) { | |
v1 = [0, 1, 0]; | |
v2 = [0, 0, 1]; | |
} else { | |
v1 = [0, 0, 1]; | |
v2 = [0, 1, 0]; | |
} | |
} else if (normal[0] === 0 && normal[2] === 0) { | |
if (normal[1] < 0) { | |
v1 = [0, 0, 1]; | |
v2 = [1, 0, 0]; | |
} else { | |
v1 = [1, 0, 0]; | |
v2 = [0, 0, 1]; | |
} | |
} else if (normal[0] === 0 && normal[1] === 0) { | |
if (normal[2] < 0) { | |
v1 = [1, 0, 0]; | |
v2 = [0, 1, 0]; | |
} else { | |
v1 = [0, 1, 0]; | |
v2 = [1, 0, 0]; | |
} | |
} else { | |
v1 = normal[2] === 0 ? [1, 0, 0] : | |
[1, 0, (normal[0] * origin[0] + normal[1] * origin[1] + normal[2] * origin[2] - | |
normal[0] * (origin[0] + 1) + normal[1] * origin[1]) / normal[2]]; | |
v1 = divide(v1, norm(v1)); | |
v2 = cross(v1, normal); | |
} | |
return [v1, v2]; | |
} | |
function rotate(center, axis, angle, vertices) { | |
axis = normalize(axis); | |
// Create a new basis, with rotation axis as the z axis | |
const planeBasis = transpose(findPlaneBasis(center, axis)); | |
const newBasis = [ | |
[...planeBasis[0], axis[0], center[0]], | |
[...planeBasis[1], axis[1], center[1]], | |
[...planeBasis[2], axis[2], center[2]], | |
[ 0, 0, 0, 1] | |
]; | |
// Create the rotation matrix | |
const rotMat = [ | |
[Math.cos(angle), -Math.sin(angle), 0, 0], | |
[Math.sin(angle), Math.cos(angle), 0, 0], | |
[ 0, 0, 1, 0], | |
[ 0, 0, 0, 1], | |
]; | |
const transformMatrix = mul(newBasis, mul(rotMat, inv(newBasis))); | |
// Rotate ! | |
return vertices.map(vertice => { | |
return mul(transformMatrix, [...vertice, 1]).slice(0, 3); | |
}); | |
} | |
// Retrieve the axis and angle from a rotation matrix M | |
function getParametersFromMatrix(M) { | |
// see https://en.wikipedia.org/wiki/Rotation_matrix#Determining_the_axis | |
const axis = normalize([ | |
M[2][1] - M[1][2], | |
M[0][2] - M[2][0], | |
M[1][0] - M[0][1], | |
]); | |
// see https://en.wikipedia.org/wiki/Rotation_matrix#Determining_the_angle | |
const angle = Math.acos((M[0][0] + M[1][1] + M[2][2] - 1) / 2); | |
return { axis, angle }; | |
} | |
const cache = {}; | |
function getOffsetCoordinates(target, event) { | |
const boundingRect = cache[target.id] !== undefined ? cache[target] : target.getBoundingClientRect(); | |
return [event.clientX - boundingRect.left, event.clientY - boundingRect.top]; | |
} | |
// The camera which is only use as an accumulator to the small rotations performed. | |
let camera = [ | |
[1, 0, 0], | |
[0, 1, 0], | |
[0, 0, 1], | |
] | |
loadScript('https://cdnjs.cloudflare.com/ajax/libs/mathjs/5.1.0/math.js'); | |
function track(scene, cube) { | |
window.sub = math.subtract; | |
window.dot = math.dot; | |
window.mul = math.multiply; | |
window.divide = math.divide; | |
window.cross = math.cross; | |
window.transpose = math.transpose; | |
window.norm = math.norm; | |
window.inv = math.inv; | |
const scene1 = document.getElementById(scene); | |
const center = [scene1.offsetWidth / 2, scene1.offsetHeight / 2, 0]; | |
const radius = scene1.offsetWidth; | |
// Core function. Takes previous and current, compute the rotation and apply it. | |
window.drag = function drag(previous, current) { | |
const increment = | |
computeRotation(center, ...projectPointOnTrackball(center, radius, previous, current)); | |
camera = rotate([0, 0, 0], increment.axis, increment.angle, camera); | |
const { axis, angle } = getParametersFromMatrix(transpose(camera)); | |
rotateCube(cube, axis, angle); | |
} | |
scene1.addEventListener('mousedown', downEvent => { | |
let previous = getOffsetCoordinates(scene1, downEvent); | |
const mousemoveHandler = moveEvent => { | |
const current = getOffsetCoordinates(scene1, moveEvent); | |
if (JSON.stringify(previous) === JSON.stringify(current)) return; | |
drag(previous, current); | |
previous = current; | |
}; | |
document.addEventListener('mousemove', mousemoveHandler); | |
// Stop listening to mousemove on mouseup | |
document.addEventListener('mouseup', _ => document.removeEventListener('mousemove', mousemoveHandler)); | |
}); | |
} | |
// Rotate 90deg to the right (facing SL) | |
// Rotate 90deg down (facing AT) like: | |
// # | |
// ### | |
// # | |
// # | |
// # | |
// # # | |
// # | |
// # | |
// drag([100, 100], [200, 100]) | |
// drag([100, 100], [100, 200]) | |
// This should give the following rotation: | |
// rotateCube('cube1', [-0.5, 0.5, -0.5], 2.10) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment