Scene.js

/**
 * @module core/Scene
 * @description Basically a camera that can rotate, pan, zoom and contain voxels
 * @requires events
**/
'use strict';

const {assign} = Object;
const {getDistance, isNumber, isShiftKey, not} = require('./common');
const events = require('./events');

const ZOOM_SCALING_FACTOR = 500;// larger --> slower zoom
const ZOOM_SCALING_FACTOR_MOBILE = 100;// larger --> slower zoom

const getX = e => (e.x || e.clientX || e.touches[0].pageX);
const getY = e => (e.y || e.clientY || e.touches[0].pageY);

// let setDebugText = val => (document.getElementById('debug').textContent = val);

module.exports = Scene;

/**
 * @name Scene
 * @constructor
 * @fires module:core/Scene~rotate
 * @fires module:core/Scene~pan
 * @fires module:core/Scene~zoom
 * @example <caption>Add a light source to a scene</caption>
 * const Voxelcss = require('voxelcss');
 * let scene = new Voxelcss.Scene();
 * let position = [300, 300, 300];
 * let distance = 750;
 * let source = new Voxelcss.LightSource(position, distance);
 * scene.attach(document.body);
 * scene.addLightSource(source);
**/
function Scene() {
    let parentContainer;
    let sceneElement;
    let zoomElement;
    let cameraElement;
    let isAttached = false;
    let x = 0;
    let y = 0;
    let z = 0;
    let rotation = {x, y, z};
    let _pan = {x, y, z};
    let _zoom = 1;
    let mouse = {
        current: {x, y},
        shiftDown: false
    };
    let initialPinchDistance = 0;
    let canRotate = true;
    let canPan = true;
    let canZoom = true;
    let lightSources = [];
    let voxels = [];
    let getVoxels = () => voxels;
    let getRotation = () => rotation;
    let getLightSources = () => lightSources;
    let self = assign(this, events, {
        attach,
        detach,
        add, // voxel
        remove, // voxel
        getVoxels,
        pan,
        setPan,
        getPan,
        rotate,
        setRotation,
        getRotation,
        zoom,
        setZoom,
        getZoom,
        addLightSource,
        getLightSources,
        removeLightSource,
        canRotate: () => canRotate,
        canPan: () => canPan,
        canZoom: () => canZoom,
        enableRotate: () => (canRotate = true),
        enablePan: () => (canPan = true),
        enableZoom: () => (canZoom = true),
        disableRotate: () => (canRotate = false),
        disablePan: () => (canPan = false),
        disableZoom: () => (canZoom = false),
        isAttached: () => isAttached,
        getRotationX: () => rotation.x,
        getRotationY: () => rotation.y,
        getRotationZ: () => rotation.z,
        rotateX: val => rotateDimension('x', val),
        rotateY: val => rotateDimension('y', val),
        rotateZ: val => rotateDimension('z', val),
        setRotationX: val => setSceneDimensionRotation('x', val),
        setRotationY: val => setSceneDimensionRotation('y', val),
        setRotationZ: val => setSceneDimensionRotation('z', val),
        panX: val => panDimension('x', val),
        panY: val => panDimension('y', val),
        panZ: val => panDimension('z', val),
        setPanX: val => setSceneDimensionPan('x', val),
        setPanY: val => setSceneDimensionPan('y', val),
        setPanZ: val => setSceneDimensionPan('z', val),
        getElement: () => sceneElement,
        getParentElement: () => parentContainer,
        getInteractionState: val => (val ? mouse[val] : mouse),
        bind: () => {
            bindMouse();
            bindKeyboard();
        },
        unbind: () => {
            unbindMouse();
            unbindKeyboard();
        }
    });
    createSceneElement();
    bindMouse();
    bindKeyboard();

    function rotate(x, y, z) {
        rotateDimension('x', x);
        rotateDimension('y', y);
        rotateDimension('z', z);
        return self;
    }
    function setRotation(x, y, z) {
        setSceneDimensionRotation('x', x);
        setSceneDimensionRotation('y', y);
        setSceneDimensionRotation('z', z);
        updateSceneTransforms();
        return self;
    }
    function rotateDimension(dim, val) {
        if (isNumber(val)) {
            rotation[dim] += val;
            updateSceneTransforms();
        }
        return self;
    }
    function setSceneDimensionRotation(dim, val) {
        if (isNumber(val)) {
            rotation[dim] = val;
            updateSceneTransforms();
        }
        return self;
    }
    function pan(x, y, z) {
        panDimension('x', x);
        panDimension('y', y);
        panDimension('z', z);
    }
    function setPan(x, y, z) {
        setSceneDimensionPan('x', x);
        setSceneDimensionPan('y', y);
        setSceneDimensionPan('z', z);
        updateSceneTransforms();
        return self;
    }
    function panDimension(dim, val) {
        if (isNumber(val)) {
            _pan[dim] += val;
            updateSceneTransforms();
        }
        return self;
    }
    function setSceneDimensionPan(dim, val) {
        if (isNumber(val)) {
            _pan[dim] = val;
            updateSceneTransforms();
        }
        return self;
    }
    function getPan() {
        return _pan;
    }
    function zoom(val) {
        if (isNumber(val)) {
            _zoom += val;
            updateSceneTransforms();
        }
        return self;
    }
    function setZoom(val) {
        if (isNumber(val)) {
            _zoom = val;
            updateSceneTransforms();
        }
        return self;
    }
    function getZoom() {
        return _zoom;
    }
    function attach(elem) {
        if (!isAttached) {
            parentContainer = elem;
            elem.appendChild(sceneElement);
            isAttached = true;
        } else {
            throw 'Cannot attach currently attached scene';
        }
    }
    function detach() {
        if (isAttached) {
            isAttached = false;
            let {parentElement} = sceneElement;
            parentElement && parentElement.removeChild(sceneElement);
        } else {
            throw 'Cannot detach currently detached scene';
        }
    }
    function add(voxel) {
        cameraElement.appendChild(voxel.getDomElement());
        voxels.push(voxel);
        voxel.setParentScene(self);
        if (lightSources.length !== 0) {voxel.updateLightSource(lightSources);}
    }
    function remove(voxel) {
        cameraElement.removeChild(voxel.getDomElement());
        voxels.splice(voxels.indexOf(voxel), 1);
        voxel.removeParentScene();
    }
    function addLightSource(source) {
        var index = lightSources.indexOf(source);
        if (index !== -1) {return false;}
        source.on('change move', updateVoxelLighting);
        lightSources.push(source);
        updateVoxelLighting();
        return true;
    }
    function removeLightSource(source) {
        var index = lightSources.indexOf(source);
        if (index === -1) {return false;}
        source.off('change move');
        lightSources.splice(index, 1);
        updateVoxelLighting();
        return true;
    }
    function createSceneElement() {
        sceneElement = document.createElement('div');
        zoomElement = document.createElement('div');
        cameraElement = document.createElement('div');
        sceneElement.setAttribute('class', 'voxelcss-scene');
        zoomElement.setAttribute('class', 'zoom');
        cameraElement.setAttribute('class', 'camera');
        sceneElement.appendChild(zoomElement);
        zoomElement.appendChild(cameraElement);
    }
    function onMouseDown(event) {
        updateMousePosition(event);
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    }
    function onMouseUp() {
        unbindMouse();
    }
    function onMouseMove(event) {
        let x = getX(event);
        let y = getY(event);
        let dx = x - mouse.current.x;
        let dy = y - mouse.current.y;
        mouse.current = {x, y};
        if (canPan && mouse.shiftDown) {
            pan(dx, dy);
            updateSceneTransforms();
            self.trigger('pan', getData());
        } else if (canRotate) {
            const rotations = 2;
            const ROTATION_SCALING_FACTOR = Math.PI * 2 * rotations;
            rotation.y += dx / window.innerWidth * ROTATION_SCALING_FACTOR;
            rotation.x -= dy / window.innerHeight * ROTATION_SCALING_FACTOR;
            updateSceneTransforms();
            self.trigger('rotate', getData());
        }
    }
    function onTouchStart(event) {
        event.preventDefault();
        const touches = event.touches;
        updateMousePosition(event);
        window.addEventListener('touchmove', onTouchMove, {passive: false});
        if (touches.length > 1) {
            initialPinchDistance = getTouchDistance(touches);
        }
    }
    function onTouchMove(event) {
        event.preventDefault();
        let x = getX(event);
        let y = getY(event);
        let dx = x - mouse.current.x;
        let dy = y - mouse.current.y;
        mouse.current = {x, y};
        const touches = event.touches;
        if ((touches.length === 1) && canRotate) {
            const rotations = 2;
            const ROTATION_SCALING_FACTOR = Math.PI * 2 * rotations;
            rotation.y += dx / window.innerWidth * ROTATION_SCALING_FACTOR;
            rotation.x -= dy / window.innerHeight * ROTATION_SCALING_FACTOR;
            updateSceneTransforms();
            self.trigger('rotate', getData());
        } else if ((touches.length === 2) && canZoom) {
            const currentPinchDistance = getTouchDistance(touches);
            const zoomIn = (currentPinchDistance - initialPinchDistance) > 0;
            const sign = zoomIn ? 1 : -1;
            zoom(sign * currentPinchDistance / (initialPinchDistance * ZOOM_SCALING_FACTOR_MOBILE));
            initialPinchDistance = currentPinchDistance;
        } else if ((touches.length === 3) && canPan) {
            pan(dx, dy);
            updateSceneTransforms();
            self.trigger('pan', getData());
        }
    }
    function onScroll(event) {
        if (canZoom) {
            zoom(event.deltaY / ZOOM_SCALING_FACTOR);
            event.preventDefault();
            self.trigger('zoom', getData());
        }
        return false;
    }
    function bindMouse() {
        sceneElement.addEventListener('mousedown', onMouseDown);
        sceneElement.addEventListener('mousewheel', onScroll);
        sceneElement.addEventListener('wheel', onScroll);
        sceneElement.addEventListener('touchstart', onTouchStart);
    }
    function bindKeyboard() {
        window.addEventListener('keydown', onKeyDown);
        window.addEventListener('keyup', onKeyUp);
    }
    function unbindMouse() {
        window.removeEventListener('mousemove', onMouseMove);
        window.removeEventListener('mouseup', onMouseUp);
    }
    function unbindKeyboard() {
        window.removeEventListener('keydown', onKeyDown);
        window.removeEventListener('keyup', onKeyUp);
    }
    function onKeyDown(event) {
        mouse.shiftDown = isShiftKey(event);
    }
    function onKeyUp(event) {
        mouse.shiftDown = not(isShiftKey)(event);
    }
    function getData() {
        return {
            rotation: getRotation(),
            pan: getPan(),
            zoom: getZoom(),
            target: self
        };
    }
    function getTouchDistance(touches) {
        const methods = [getX, getY];
        const [x0, y0] = methods.map(method => method(touches.item(0)));
        const [x1, y1] = methods.map(method => method(touches.item(1)));
        const dX = x1 - x0;
        const dY = y1 - y0;
        return getDistance(dX, dY);
    }
    function updateSceneTransforms() {
        let {x, y, z} = getRotation();
        let zoom = getZoom();
        let pan = getPan();
        cameraElement.style.transform = `rotateX(${x}rad) rotateY(${y}rad) rotateZ(${z}rad)`;
        zoomElement.style.transform = `scale(${zoom}, ${zoom}) translateX(${pan.x}px) translateY(${pan.y}px) translateZ(${pan.z}px)`;
        updateVoxelLighting();
    }
    function updateVoxelLighting() {
        if (lightSources.length !== 0) {
            voxels.forEach(voxel => voxel.updateLightSource(lightSources));
        }
    }
    function updateMousePosition(event) {
        const x = getX(event);
        const y = getY(event);
        mouse.current = {x, y};
    }
}