UNPKG

@inweb/viewer-three

Version:

JavaScript library for rendering CAD and BIM files in a browser using Three.js

1,007 lines (722 loc) 28.2 kB
import { EventDispatcher, MOUSE, Quaternion, Spherical, TOUCH, Vector2, Vector3 } from "three"; // OrbitControls performs orbiting, dollying (zooming), and panning. // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). // // Orbit - left mouse / touch: one-finger move // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move const _changeEvent = { type: "change" }; const _startEvent = { type: "start" }; const _endEvent = { type: "end" }; const STATE = { NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_PAN: 4, TOUCH_DOLLY_PAN: 5, TOUCH_DOLLY_ROTATE: 6, }; class OrbitControls extends EventDispatcher { constructor(object, domElement) { super(); this.object = object; this.domElement = domElement; this.domElement.style.touchAction = "none"; // disable touch scroll // Set to false to disable this control this.enabled = true; // "target" sets the location of focus, where the object orbits around this.target = new Vector3(); // How far you can dolly in and out ( PerspectiveCamera only ) this.minDistance = 0; this.maxDistance = Infinity; // How far you can zoom in and out ( OrthographicCamera only ) this.minZoom = 0; this.maxZoom = Infinity; // How far you can orbit vertically, upper and lower limits. // Range is 0 to Math.PI radians. this.minPolarAngle = 0; // radians this.maxPolarAngle = Math.PI; // radians // How far you can orbit horizontally, upper and lower limits. // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) this.minAzimuthAngle = -Infinity; // radians this.maxAzimuthAngle = Infinity; // radians // Set to true to enable damping (inertia) // If damping is enabled, you must call controls.update() in your animation loop this.enableDamping = false; this.dampingFactor = 0.05; // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. // Set to false to disable zooming this.enableZoom = true; this.zoomSpeed = 1.0; // Set to false to disable rotating this.enableRotate = true; this.rotateSpeed = 1.0; // Set to false to disable panning this.enablePan = true; this.panSpeed = 1.0; this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up this.keyPanSpeed = 7.0; // pixels moved per arrow key push // Set to true to automatically rotate around the target // If auto-rotate is enabled, you must call controls.update() in your animation loop this.autoRotate = false; this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60 // The four arrow keys this.keys = { LEFT: "ArrowLeft", UP: "ArrowUp", RIGHT: "ArrowRight", BOTTOM: "ArrowDown" }; // Mouse buttons this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }; // Touch fingers this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }; // for reset this.target0 = this.target.clone(); this.position0 = this.object.position.clone(); this.zoom0 = this.object.zoom; // the target DOM element for key events this._domElementKeyEvents = null; // // public methods // this.getPolarAngle = function () { return spherical.phi; }; this.getAzimuthalAngle = function () { return spherical.theta; }; this.getDistance = function () { return this.object.position.distanceTo(this.target); }; this.listenToKeyEvents = function (domElement) { domElement.addEventListener("keydown", onKeyDown); this._domElementKeyEvents = domElement; }; this.stopListenToKeyEvents = function () { this._domElementKeyEvents.removeEventListener("keydown", onKeyDown); this._domElementKeyEvents = null; }; this.saveState = function () { scope.target0.copy(scope.target); scope.position0.copy(scope.object.position); scope.zoom0 = scope.object.zoom; }; this.reset = function () { scope.target.copy(scope.target0); scope.object.position.copy(scope.position0); scope.object.zoom = scope.zoom0; scope.object.updateProjectionMatrix(); scope.dispatchEvent(_changeEvent); scope.update(); scope.state = STATE.NONE; }; // this method is exposed, but perhaps it would be better if we can make it private... this.update = (function () { const offset = new Vector3(); // so camera.up is the orbit axis const quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)); const quatInverse = quat.clone().invert(); const lastPosition = new Vector3(); const lastQuaternion = new Quaternion(); const lastTargetPosition = new Vector3(); const twoPI = 2 * Math.PI; return function update() { const position = scope.object.position; offset.copy(position).sub(scope.target); // rotate offset to "y-axis-is-up" space offset.applyQuaternion(quat); // angle from z-axis around y-axis spherical.setFromVector3(offset); if (scope.autoRotate && scope.state === STATE.NONE) { rotateLeft(getAutoRotationAngle()); } if (scope.enableDamping) { spherical.theta += sphericalDelta.theta * scope.dampingFactor; spherical.phi += sphericalDelta.phi * scope.dampingFactor; } else { spherical.theta += sphericalDelta.theta; spherical.phi += sphericalDelta.phi; } // restrict theta to be between desired limits let min = scope.minAzimuthAngle; let max = scope.maxAzimuthAngle; if (isFinite(min) && isFinite(max)) { if (min < -Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI; if (max < -Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI; if (min <= max) { spherical.theta = Math.max(min, Math.min(max, spherical.theta)); } else { spherical.theta = spherical.theta > (min + max) / 2 ? Math.max(min, spherical.theta) : Math.min(max, spherical.theta); } } // restrict phi to be between desired limits spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi)); spherical.makeSafe(); spherical.radius *= scope.scale; // restrict radius to be between desired limits spherical.radius = Math.max(scope.minDistance, Math.min(scope.maxDistance, spherical.radius)); // move target to panned location if (scope.enableDamping === true) { scope.target.addScaledVector(scope.panOffset, scope.dampingFactor); } else { scope.target.add(scope.panOffset); } offset.setFromSpherical(spherical); // rotate offset back to "camera-up-vector-is-up" space offset.applyQuaternion(quatInverse); position.copy(scope.target).add(offset); scope.object.lookAt(scope.target); if (scope.enableDamping === true) { sphericalDelta.theta *= 1 - scope.dampingFactor; sphericalDelta.phi *= 1 - scope.dampingFactor; scope.panOffset.multiplyScalar(1 - scope.dampingFactor); } else { sphericalDelta.set(0, 0, 0); scope.panOffset.set(0, 0, 0); } scope.scale = 1; // update condition is: // min(camera displacement, camera rotation in radians)^2 > EPS // using small-angle approximation cos(x/2) = 1 - x^2 / 8 if ( scope.zoomChanged || lastPosition.distanceToSquared(scope.object.position) > EPS || 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS || lastTargetPosition.distanceToSquared(scope.target) > 0 ) { scope.dispatchEvent(_changeEvent); lastPosition.copy(scope.object.position); lastQuaternion.copy(scope.object.quaternion); lastTargetPosition.copy(scope.target); scope.zoomChanged = false; return true; } return false; }; })(); this.dispose = function () { scope.domElement.removeEventListener("contextmenu", onContextMenu); scope.domElement.removeEventListener("pointerdown", onPointerDown); scope.domElement.removeEventListener("pointercancel", onPointerUp); scope.domElement.removeEventListener("wheel", onMouseWheel); scope.domElement.removeEventListener("pointermove", onPointerMove); scope.domElement.removeEventListener("pointerup", onPointerUp); if (scope._domElementKeyEvents !== null) { scope._domElementKeyEvents.removeEventListener("keydown", onKeyDown); scope._domElementKeyEvents = null; } //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? }; // // internals // const scope = this; scope.state = STATE.NONE; const EPS = 0.000001; // current position in spherical coordinates const spherical = new Spherical(); const sphericalDelta = new Spherical(); scope.scale = 1; scope.panOffset = new Vector3(); scope.zoomChanged = false; scope.rotateStart = new Vector2(); scope.rotateEnd = new Vector2(); scope.rotateDelta = new Vector2(); scope.panStart = new Vector2(); scope.panEnd = new Vector2(); scope.panDelta = new Vector2(); scope.dollyStart = new Vector2(); scope.dollyEnd = new Vector2(); scope.dollyDelta = new Vector2(); scope.dollyScale = 0; scope.pointers = []; scope.pointerPositions = {}; function getAutoRotationAngle() { return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed; } function getZoomScale() { return Math.pow(0.95, scope.zoomSpeed); } function rotateLeft(angle) { sphericalDelta.theta -= angle; } function rotateUp(angle) { sphericalDelta.phi -= angle; } const panLeft = (function () { const v = new Vector3(); return function panLeft(distance, objectMatrix) { v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix v.multiplyScalar(-distance); scope.panOffset.add(v); }; })(); const panUp = (function () { const v = new Vector3(); return function panUp(distance, objectMatrix) { if (scope.screenSpacePanning === true) { v.setFromMatrixColumn(objectMatrix, 1); } else { v.setFromMatrixColumn(objectMatrix, 0); v.crossVectors(scope.object.up, v); } v.multiplyScalar(distance); scope.panOffset.add(v); }; })(); // deltaX and deltaY are in pixels; right and down are positive const pan = (function () { const offset = new Vector3(); return function pan(deltaX, deltaY) { const element = scope.domElement; if (scope.object.isPerspectiveCamera) { // perspective const position = scope.object.position; offset.copy(position).sub(scope.target); let targetDistance = offset.length(); // half of the fov is center to top of screen targetDistance *= Math.tan(((scope.object.fov / 2) * Math.PI) / 180.0); // we use only clientHeight here so aspect ratio does not distort speed panLeft((2 * deltaX * targetDistance) / element.clientHeight, scope.object.matrix); panUp((2 * deltaY * targetDistance) / element.clientHeight, scope.object.matrix); } else if (scope.object.isOrthographicCamera) { // orthographic panLeft( (deltaX * (scope.object.right - scope.object.left)) / scope.object.zoom / element.clientWidth, scope.object.matrix ); panUp( (deltaY * (scope.object.top - scope.object.bottom)) / scope.object.zoom / element.clientHeight, scope.object.matrix ); } else { // camera neither orthographic nor perspective console.warn("WARNING: OrbitControls.js encountered an unknown camera type - pan disabled."); scope.enablePan = false; } }; })(); function dollyOut(dollyScale) { if (scope.object.isPerspectiveCamera) { scope.scale /= dollyScale; } else if (scope.object.isOrthographicCamera) { scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom * dollyScale)); scope.object.updateProjectionMatrix(); scope.zoomChanged = true; } else { console.warn("WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled."); scope.enableZoom = false; } } function dollyIn(dollyScale) { if (scope.object.isPerspectiveCamera) { scope.scale *= dollyScale; } else if (scope.object.isOrthographicCamera) { scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / dollyScale)); scope.object.updateProjectionMatrix(); scope.zoomChanged = true; } else { console.warn("WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled."); scope.enableZoom = false; } } // // event callbacks - update the object state // function handleMouseDownRotate(event) { scope.rotateStart.set(event.clientX, event.clientY); } function handleMouseDownDolly(event) { scope.dollyStart.set(event.clientX, event.clientY); } function handleMouseDownPan(event) { scope.panStart.set(event.clientX, event.clientY); } function handleMouseMoveRotate(event) { scope.rotateEnd.set(event.clientX, event.clientY); scope.rotateDelta.subVectors(scope.rotateEnd, scope.rotateStart).multiplyScalar(scope.rotateSpeed); const element = scope.domElement; rotateLeft((2 * Math.PI * scope.rotateDelta.x) / element.clientHeight); // yes, height rotateUp((2 * Math.PI * scope.rotateDelta.y) / element.clientHeight); scope.rotateStart.copy(scope.rotateEnd); scope.update(); } function handleMouseMoveDolly(event) { scope.dollyEnd.set(event.clientX, event.clientY); scope.dollyDelta.subVectors(scope.dollyEnd, scope.dollyStart); if (scope.dollyDelta.y < 0) { scope.dollyScale = 1 / getZoomScale(); dollyOut(getZoomScale()); } else if (scope.dollyDelta.y > 0) { scope.dollyScale = getZoomScale(); dollyIn(getZoomScale()); } scope.dollyStart.copy(scope.dollyEnd); scope.update(); } function handleMouseMovePan(event) { scope.panEnd.set(event.clientX, event.clientY); scope.panDelta.subVectors(scope.panEnd, scope.panStart).multiplyScalar(scope.panSpeed); pan(scope.panDelta.x, scope.panDelta.y); scope.panStart.copy(scope.panEnd); scope.update(); } function handleMouseWheel(event) { scope.dollyEnd.set(scope.domElement.clientWidth / 2, scope.domElement.clientHeight / 2); scope.dollyDelta.set(event.deltaX, event.deltaY); if (event.deltaY < 0) { scope.dollyScale = 1 / getZoomScale(); dollyIn(getZoomScale()); } else if (event.deltaY > 0) { scope.dollyScale = getZoomScale(); dollyOut(getZoomScale()); } scope.dollyStart.copy(scope.dollyEnd); scope.update(); if (event.deltaY !== 0) { scope.state = STATE.DOLLY; scope.dispatchEvent(_changeEvent); scope.state = STATE.NONE; } } function handleKeyDown(event) { let needsUpdate = false; switch (event.code) { case scope.keys.UP: if (event.ctrlKey || event.metaKey || event.shiftKey) { rotateUp((2 * Math.PI * scope.rotateSpeed) / scope.domElement.clientHeight); } else { pan(0, scope.keyPanSpeed); } needsUpdate = true; break; case scope.keys.BOTTOM: if (event.ctrlKey || event.metaKey || event.shiftKey) { rotateUp((-2 * Math.PI * scope.rotateSpeed) / scope.domElement.clientHeight); } else { pan(0, -scope.keyPanSpeed); } needsUpdate = true; break; case scope.keys.LEFT: if (event.ctrlKey || event.metaKey || event.shiftKey) { rotateLeft((2 * Math.PI * scope.rotateSpeed) / scope.domElement.clientHeight); } else { pan(scope.keyPanSpeed, 0); } needsUpdate = true; break; case scope.keys.RIGHT: if (event.ctrlKey || event.metaKey || event.shiftKey) { rotateLeft((-2 * Math.PI * scope.rotateSpeed) / scope.domElement.clientHeight); } else { pan(-scope.keyPanSpeed, 0); } needsUpdate = true; break; } if (needsUpdate) { // prevent the browser from scrolling on cursor keys event.preventDefault(); scope.update(); } } function handleTouchStartRotate() { if (scope.pointers.length === 1) { scope.rotateStart.set(scope.pointers[0].pageX, scope.pointers[0].pageY); } else { const x = 0.5 * (scope.pointers[0].pageX + scope.pointers[1].pageX); const y = 0.5 * (scope.pointers[0].pageY + scope.pointers[1].pageY); scope.rotateStart.set(x, y); } } function handleTouchStartPan() { if (scope.pointers.length === 1) { scope.panStart.set(scope.pointers[0].pageX, scope.pointers[0].pageY); } else { const x = 0.5 * (scope.pointers[0].pageX + scope.pointers[1].pageX); const y = 0.5 * (scope.pointers[0].pageY + scope.pointers[1].pageY); scope.panStart.set(x, y); } } function handleTouchStartDolly() { const dx = scope.pointers[0].pageX - scope.pointers[1].pageX; const dy = scope.pointers[0].pageY - scope.pointers[1].pageY; const distance = Math.sqrt(dx * dx + dy * dy); scope.dollyStart.set(0, distance); } function handleTouchStartDollyPan() { if (scope.enableZoom) handleTouchStartDolly(); if (scope.enablePan) handleTouchStartPan(); } function handleTouchStartDollyRotate() { if (scope.enableZoom) handleTouchStartDolly(); if (scope.enableRotate) handleTouchStartRotate(); } function handleTouchMoveRotate(event) { if (scope.pointers.length == 1) { scope.rotateEnd.set(event.pageX, event.pageY); } else { const position = getSecondPointerPosition(event); const x = 0.5 * (event.pageX + position.x); const y = 0.5 * (event.pageY + position.y); scope.rotateEnd.set(x, y); } scope.rotateDelta.subVectors(scope.rotateEnd, scope.rotateStart).multiplyScalar(scope.rotateSpeed); const element = scope.domElement; rotateLeft((2 * Math.PI * scope.rotateDelta.x) / element.clientHeight); // yes, height rotateUp((2 * Math.PI * scope.rotateDelta.y) / element.clientHeight); scope.rotateStart.copy(scope.rotateEnd); } function handleTouchMovePan(event) { if (scope.pointers.length === 1) { scope.panEnd.set(event.pageX, event.pageY); } else { const position = getSecondPointerPosition(event); const x = 0.5 * (event.pageX + position.x); const y = 0.5 * (event.pageY + position.y); scope.panEnd.set(x, y); } scope.panDelta.subVectors(scope.panEnd, scope.panStart).multiplyScalar(scope.panSpeed); pan(scope.panDelta.x, scope.panDelta.y); scope.panStart.copy(scope.panEnd); } function handleTouchMoveDolly(event) { const position = getSecondPointerPosition(event); const dx = event.pageX - position.x; const dy = event.pageY - position.y; const distance = Math.sqrt(dx * dx + dy * dy); scope.dollyEnd.set(0, distance); scope.dollyDelta.set(0, Math.pow(scope.dollyEnd.y / scope.dollyStart.y, scope.zoomSpeed)); dollyOut(scope.dollyDelta.y); scope.dollyStart.copy(scope.dollyEnd); } function handleTouchMoveDollyPan(event) { if (scope.enableZoom) handleTouchMoveDolly(event); if (scope.enablePan) handleTouchMovePan(event); } function handleTouchMoveDollyRotate(event) { if (scope.enableZoom) handleTouchMoveDolly(event); if (scope.enableRotate) handleTouchMoveRotate(event); } // // event handlers - FSM: listen for events and reset state // function onPointerDown(event) { if (scope.enabled === false) return; if (scope.pointers.length === 0) { scope.domElement.setPointerCapture(event.pointerId); scope.domElement.addEventListener("pointermove", onPointerMove); scope.domElement.addEventListener("pointerup", onPointerUp); } // addPointer(event); if (event.pointerType === "touch") { onTouchStart(event); } else { onMouseDown(event); } } function onPointerMove(event) { if (scope.enabled === false) return; if (event.pointerType === "touch") { onTouchMove(event); } else { onMouseMove(event); } } function onPointerUp(event) { removePointer(event); if (scope.pointers.length === 0) { scope.domElement.releasePointerCapture(event.pointerId); scope.domElement.removeEventListener("pointermove", onPointerMove); scope.domElement.removeEventListener("pointerup", onPointerUp); } scope.dispatchEvent(_endEvent); scope.state = STATE.NONE; } function onMouseDown(event) { let mouseAction; switch (event.button) { case 0: mouseAction = scope.mouseButtons.LEFT; break; case 1: mouseAction = scope.mouseButtons.MIDDLE; break; case 2: mouseAction = scope.mouseButtons.RIGHT; break; default: mouseAction = -1; } switch (mouseAction) { case MOUSE.DOLLY: if (scope.enableZoom === false) return; handleMouseDownDolly(event); scope.state = STATE.DOLLY; break; case MOUSE.ROTATE: if (event.ctrlKey || event.metaKey || event.shiftKey) { if (scope.enablePan === false) return; handleMouseDownPan(event); scope.state = STATE.PAN; } else { if (scope.enableRotate === false) return; handleMouseDownRotate(event); scope.state = STATE.ROTATE; } break; case MOUSE.PAN: if (event.ctrlKey || event.metaKey || event.shiftKey) { if (scope.enableRotate === false) return; handleMouseDownRotate(event); scope.state = STATE.ROTATE; } else { if (scope.enablePan === false) return; handleMouseDownPan(event); scope.state = STATE.PAN; } break; default: scope.state = STATE.NONE; } if (scope.state !== STATE.NONE) { scope.dispatchEvent(_startEvent); } } function onMouseMove(event) { switch (scope.state) { case STATE.ROTATE: if (scope.enableRotate === false) return; handleMouseMoveRotate(event); break; case STATE.DOLLY: if (scope.enableZoom === false) return; handleMouseMoveDolly(event); break; case STATE.PAN: if (scope.enablePan === false) return; handleMouseMovePan(event); break; } } function onMouseWheel(event) { if (scope.enabled === false || scope.enableZoom === false || scope.state !== STATE.NONE) return; event.preventDefault(); scope.dispatchEvent(_startEvent); handleMouseWheel(event); scope.dispatchEvent(_endEvent); } function onKeyDown(event) { if (scope.enabled === false || scope.enablePan === false) return; handleKeyDown(event); } function onTouchStart(event) { trackPointer(event); switch (scope.pointers.length) { case 1: switch (scope.touches.ONE) { case TOUCH.ROTATE: if (scope.enableRotate === false) return; handleTouchStartRotate(); scope.state = STATE.TOUCH_ROTATE; break; case TOUCH.PAN: if (scope.enablePan === false) return; handleTouchStartPan(); scope.state = STATE.TOUCH_PAN; break; default: scope.state = STATE.NONE; } break; case 2: switch (scope.touches.TWO) { case TOUCH.DOLLY_PAN: if (scope.enableZoom === false && scope.enablePan === false) return; handleTouchStartDollyPan(); scope.state = STATE.TOUCH_DOLLY_PAN; break; case TOUCH.DOLLY_ROTATE: if (scope.enableZoom === false && scope.enableRotate === false) return; handleTouchStartDollyRotate(); scope.state = STATE.TOUCH_DOLLY_ROTATE; break; default: scope.state = STATE.NONE; } break; default: scope.state = STATE.NONE; } if (scope.state !== STATE.NONE) { scope.dispatchEvent(_startEvent); } } function onTouchMove(event) { trackPointer(event); switch (scope.state) { case STATE.TOUCH_ROTATE: if (scope.enableRotate === false) return; handleTouchMoveRotate(event); scope.update(); break; case STATE.TOUCH_PAN: if (scope.enablePan === false) return; handleTouchMovePan(event); scope.update(); break; case STATE.TOUCH_DOLLY_PAN: if (scope.enableZoom === false && scope.enablePan === false) return; handleTouchMoveDollyPan(event); scope.update(); break; case STATE.TOUCH_DOLLY_ROTATE: if (scope.enableZoom === false && scope.enableRotate === false) return; handleTouchMoveDollyRotate(event); scope.update(); break; default: scope.state = STATE.NONE; } } function onContextMenu(event) { if (scope.enabled === false) return; event.preventDefault(); } function addPointer(event) { scope.pointers.push(event); } function removePointer(event) { delete scope.pointerPositions[event.pointerId]; for (let i = 0; i < scope.pointers.length; i++) { if (scope.pointers[i].pointerId == event.pointerId) { scope.pointers.splice(i, 1); return; } } } function trackPointer(event) { let position = scope.pointerPositions[event.pointerId]; if (position === undefined) { position = new Vector2(); scope.pointerPositions[event.pointerId] = position; } position.set(event.pageX, event.pageY); } function getSecondPointerPosition(event) { const pointer = event.pointerId === scope.pointers[0].pointerId ? scope.pointers[1] : scope.pointers[0]; return scope.pointerPositions[pointer.pointerId]; } // scope.domElement.addEventListener("contextmenu", onContextMenu); scope.domElement.addEventListener("pointerdown", onPointerDown); scope.domElement.addEventListener("pointercancel", onPointerUp); scope.domElement.addEventListener("wheel", onMouseWheel, { passive: false }); // force an update at start this.update(); } } export { OrbitControls, STATE };