Voxel.js

/**
 * @module core/Voxel
 * @requires common
 * @requires events
 * @requires positioned
 * @requires core/Mesh
 * @requires core/ImageFace
 * @requires core/ColorFace
 **/
'use strict';

const {assign, keys} = Object;
const {abs, asin, max, min, PI, pow, sqrt} = Math;
const {
    generateRotationMatrix,
    isNumber,
    isUndefined,
    multiplyMatrices
} = require('./common');
const events     = require('./events');
const positioned = require('./positioned');
const Mesh       = require('./Mesh');
const ImageFace  = require('./ImageFace');
const ColorFace  = require('./ColorFace');

const LONG_PRESS_DURATION = 250;
const DEFAULT_SIZE = 50;
const SIDES = ['top', 'bottom', 'front', 'back', 'left', 'right'];
const pixels = val => `${val}px`;
const translate = val => `translateZ(${val}px)`;
const transformX = val => `rotateX(90deg) ${translate(val)}`;
const transformY = val => `rotateY(90deg) ${translate(val)}`;

module.exports = Voxel;

/**
 * @name Voxel
 * @constructor
 * @param {number[]} [position] [x, y, z] of voxel
 * @param {number} [size=50] Size of voxel
 * @param {object} [options={}] Options to customize voxel
 * @param {Mesh} [options.mesh] Voxel mesh
**/
function Voxel(position = [0, 0, 0], size = DEFAULT_SIZE, options = {}) {
    let longTouchTimer;
    let cubeElement;
    let animElement;
    let parentScene;
    let mesh;
    let faces = {};
    let dimension = 0;
    let self = assign(positioned(assign(this, events)), {
        clone,
        animUp,
        animDown,
        addToScene,
        removeFromScene,
        setParentScene,
        removeParentScene,
        setDimension,
        getDimension,
        updateLightSource,
        setMesh,
        getMesh: () => mesh,
        getDomElement: () => cubeElement,
        getAnimatedElement: () => animElement
    });
    self.on('move', updatePosition);
    setDimension(size);
    createCube();
    self.setPosition(position);
    setMesh(isUndefined(options.mesh) ? new Mesh() : options.mesh);
    function setMesh(data = {}) {
        if (data.constructor === Mesh) {
            let old = mesh;
            old && old.off('change');
            mesh = data;
            mesh.on('change', () => {
                applyMesh();
                const target = self;
                self.trigger('change:mesh', {target, mesh});
            });
            applyMesh();
        }
    }
    function animUp(scene) {
        if (scene) {
            parentScene = scene;
            animElement.setAttribute('class', 'animated-up');
            appendToScene();
        } else {
            throw 'Scene required to add voxel to scene';
        }
    }
    function animDown(scene) {
        if (scene) {
            parentScene = scene;
            animElement.setAttribute('class', 'animated-down');
            appendToScene();
        } else {
            throw 'Scene required to add voxel to scene';
        }
    }
    function addToScene(scene) {
        if (scene) {
            parentScene = scene;
            animElement.setAttribute('class', 'animated');
            appendToScene();
        } else {
            throw 'Scene required to add voxel to scene';
        }
    }
    function appendToScene() {
        parentScene.add(self);
    }
    function removeFromScene() {
        parentScene && parentScene.remove(self);
    }
    function setParentScene(scene) {
        parentScene = scene;
    }
    function removeParentScene() {
        parentScene = undefined;
    }
    function setDimension(val) {
        if (isNumber(val)) {
            dimension = val;
        }
    }
    function getDimension() {
        return dimension;
    }
    function clone() {
        return new Voxel([self.getPositionX(), self.getPositionY(), self.getPositionZ()], dimension, {mesh});
    }
    function updateLightSource(lightSources) {
        const cubed = val => pow(val, 3);
        let front = 1;
        let back = 1;
        let left = 1;
        let right = 1;
        let top = 1;
        let bottom = 1;
        lightSources.forEach(lightSource => {
            let position = [
                lightSource.getPositionX(),
                lightSource.getPositionY(),
                lightSource.getPositionZ()
            ];
            let brightness = lightSource.getBrightness();
            let travelDistance = lightSource.getTravelDistance();
            let scale = brightness[1] - brightness[0];
            let shift = 1 - brightness[1];
            let calculateOpacity = unitVector => {/* eslint-disable no-magic-numbers */
                let [A, B, C] = unitVector;
                let {angle, direction, distance} = angleFromLightSource(position, {A, B, C});
                let percent = (direction < 0) ? 1 : min(1, cubed(1 - angle / (PI / 2)) + pow(distance / travelDistance, 6));
                return 1 - (percent * scale + shift);
            };/* eslint-enable no-magic-numbers */
            back = max(0, back - calculateOpacity([0, 0, -1]));
            front = max(0, front - calculateOpacity([0, 0, 1]));
            left = max(0, left - calculateOpacity([-1, 0, 0]));
            right = max(0, right - calculateOpacity([1, 0, 0]));
            top = max(0, top - calculateOpacity([0, 1, 0]));
            bottom = max(0, bottom - calculateOpacity([0, -1, 0]));
        });
        const sides = {front, back, left, right, top, bottom};
        keys(sides).forEach((side) => {
            faces[side].shader.style.opacity = sides[side];
        });
    }
    function angleFromLightSource(position, plane) {
        let xRotation = parentScene.getRotationX();
        let yRotation = parentScene.getRotationY();
        let zRotation = parentScene.getRotationZ();
        let rotationMatrix = generateRotationMatrix(xRotation, -yRotation, zRotation);
        let {A, B, C} = plane;
        let rotated = rotate(position, rotationMatrix);
        rotated = {
            x: rotated.x - self.getPositionX() - A * getDimension() / 2,
            y: rotated.y - self.getPositionY() - B * getDimension() / 2,
            z: rotated.z - self.getPositionZ() - C * getDimension() / 2
        };
        let {x, y, z} = rotated;
        let distance = sqrt(pow(x, 2) + pow(y, 2) + pow(z, 2));
        let direction = abs(C) === 1 ? C * z : (abs(B) === 1 ? B * y : A * x);
        let angle = asin(abs(x * A + y * B + z * C) / distance);
        return {angle, direction, distance};
    }
    function rotate(point, rotationMatrix) {
        let [x, y, z] = point;
        let columnVector = [[x], [y], [z]];
        let rotated = multiplyMatrices(rotationMatrix, columnVector);
        return {
            x: rotated[0][0],
            y: rotated[1][0],
            z: rotated[2][0]
        };
    }
    function applyMesh() {
        let data = mesh.getFaces();
        SIDES.forEach(side => {
            let faceMesh = data[side];
            let face = faces[side];
            if (faceMesh instanceof ImageFace) {
                face.src = faceMesh.getSource();
                face.removeAttribute('class');
            }
            if (faceMesh instanceof ColorFace) {
                let faceElem = face.parentElement;
                faceElem.style.background = '#' + faceMesh.getHex();
                face.setAttribute('class', 'colored');
            }
        });
    }
    function createCube() {
        cubeElement = createElement('div', 'voxelcss-cube');
        animElement = createElement('div', 'animated');
        SIDES.forEach(side => createFace(side));
        cubeElement.appendChild(animElement);
    }
    function createFace(label) {
        const target = self;
        const transformLookup = {
            top:    transformX(dimension / 2),
            bottom: transformX(-dimension / 2),
            left:   transformY(-dimension / 2),
            right:  transformY(dimension / 2),
            front:  translate(dimension / 2),
            back:   translate(-dimension / 2)
        };
        const handlerLookup = SIDES.reduce((lookup, side) => {
            return assign(lookup, {[side]: () => self.trigger(`click:${side}`, {target})});
        }, {});
        const image = createElement('img', '');
        const shader = createElement('div', 'shader');
        const wrapper = createElement('div', 'voxelcss-face ' + label);
        faces[label] = assign(image, {shader});
        assign(wrapper.style, {
            width:      pixels(dimension),
            height:     pixels(dimension),
            marginLeft: pixels(-1 * dimension / 2),
            marginTop:  pixels(-1 * dimension / 2),
            transform:  transformLookup[label]
        });
        wrapper.addEventListener('click', handlerLookup[label]);
        wrapper.addEventListener('contextmenu', e => {
            e.preventDefault();
            self.trigger('contextmenu', {target: self});
            return false;
        });
        wrapper.addEventListener('touchstart', () => {
            longTouchTimer = setTimeout(() => self.trigger('contextmenu', {target: self}), LONG_PRESS_DURATION);
        });
        wrapper.addEventListener('touchend', e => {
            e.preventDefault();
            clearTimeout(longTouchTimer);
            handlerLookup[label]();
        });
        wrapper.appendChild(image);
        wrapper.appendChild(shader);
        animElement.appendChild(wrapper);
    }
    function createElement(type, className) {
        const elem = document.createElement(type);
        elem.setAttribute('class', className);
        return elem;
    }
    function updatePosition() {
        const {x, y, z} = self.getPosition();
        cubeElement.style.transform = `translate3d(${x}px, ${-y}px, ${z}px)`;
    }
}