UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

825 lines (516 loc) • 23.3 kB
import { Quaternion, Raycaster, Vector3 } from 'three'; import { assert } from "../../../src/core/assert.js"; import { SurfacePoint3 } from "../../../src/core/geom/3d/SurfacePoint3.js"; import { EntityNode } from "../../../src/engine/ecs/parent/EntityNode.js"; import { Transform } from "../../../src/engine/ecs/transform/Transform.js"; import { ShadedGeometry } from "../../../src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js"; import { ShadedGeometryFlags } from "../../../src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js"; import { FrameRunner } from "../../../src/engine/graphics/FrameRunner.js"; import { GizmoNode } from "./GizmoNode.js"; import { TransformControlsGizmo } from "./TransformControlsGizmo.js"; import { TransformControlsPlane } from "./TransformControlsPlane.js"; import { TransformMode } from "./TransformMode.js"; const _raycaster = new Raycaster(); const _tempVector2 = new Vector3(); const _unit = { X: new Vector3(1, 0, 0), Y: new Vector3(0, 1, 0), Z: new Vector3(0, 0, 1) }; const _changeEvent = { type: 'change' }; const _mouseDownEvent = { type: 'mouseDown' }; const _mouseUpEvent = { type: 'mouseUp', mode: null }; const _objectChangeEvent = { type: 'objectChange' }; const _tempVector = new Vector3(); const _tempQuaternion = new Quaternion(); /** * @readonly * @type {Transform} */ const _tempTransform = new Transform(); class TransformControls extends GizmoNode { /** * Entity ID that we are controlling. -1 indicates no entity * @type {number} */ object = -1; /** * * @param {THREE.Camera} camera * @param {HTMLElement} domElement * @param {boolean} [autoUpdate] Will attempt to update automatically, unnecessary computation can be avoided by manually invoking "update" function each frame instead */ constructor(camera, domElement, autoUpdate = true) { super(); if (domElement === undefined) { console.warn('TransformControls: The second parameter "domElement" is now mandatory.'); domElement = document; } this.isTransformControls = true; this.visible = false; this.domElement = domElement; try { this.domElement.style.touchAction = 'none'; // disable touch scroll } catch (e) { // console.error("Failed to disable touch scroll on domElement:", e); } const _gizmo = new TransformControlsGizmo(); this._gizmo = _gizmo; this.addChild(_gizmo); const _plane = new TransformControlsPlane(); this._plane = _plane; this.addChild(_plane); const scope = this; // Defined getter, setter and store for a property function defineProperty(propName, defaultValue) { let propValue = defaultValue; Object.defineProperty(scope, propName, { get: function () { return propValue !== undefined ? propValue : defaultValue; }, set: function (value) { if (propValue !== value) { propValue = value; _plane[propName] = value; _gizmo[propName] = value; scope.entity.sendEvent(propName + '-changed', value); } } }); scope[propName] = defaultValue; _plane[propName] = defaultValue; _gizmo[propName] = defaultValue; } // Define properties with getters/setter // Setting the defined property will automatically trigger change event // Defined properties are passed down to gizmo and plane defineProperty('camera', camera); defineProperty('object', undefined); defineProperty('enabled', true); defineProperty('axis', null); defineProperty('mode', TransformMode.Translate); defineProperty('translationSnap', null); defineProperty('rotationSnap', null); defineProperty('scaleSnap', null); defineProperty('space', 'world'); defineProperty('size', 1); defineProperty('dragging', false); defineProperty('showX', true); defineProperty('showY', true); defineProperty('showZ', true); // Reusable utility variables const worldPosition = new Vector3(); const worldPositionStart = new Vector3(); const worldQuaternion = new Quaternion(); const worldQuaternionStart = new Quaternion(); const cameraPosition = new Vector3(); const cameraQuaternion = new Quaternion(); const pointStart = new Vector3(); const pointEnd = new Vector3(); const rotationAxis = new Vector3(); const rotationAngle = 0; const eye = new Vector3(); // TODO: remove properties unused in plane and gizmo defineProperty('worldPosition', worldPosition); defineProperty('worldPositionStart', worldPositionStart); defineProperty('worldQuaternion', worldQuaternion); defineProperty('worldQuaternionStart', worldQuaternionStart); defineProperty('cameraPosition', cameraPosition); defineProperty('cameraQuaternion', cameraQuaternion); defineProperty('pointStart', pointStart); defineProperty('pointEnd', pointEnd); defineProperty('rotationAxis', rotationAxis); defineProperty('rotationAngle', rotationAngle); defineProperty('eye', eye); this._offset = new Vector3(); this._startNorm = new Vector3(); this._endNorm = new Vector3(); this._cameraScale = new Vector3(1, 1, 1); this._parentPosition = new Vector3(); this._parentQuaternion = new Quaternion(); this._parentQuaternionInv = new Quaternion(); this._parentScale = new Vector3(1, 1, 1); this._worldScaleStart = new Vector3(1, 1, 1); this._worldQuaternionInv = new Quaternion(); this._worldScale = new Vector3(1, 1, 1); this._positionStart = new Vector3(); this._quaternionStart = new Quaternion(); this._scaleStart = new Vector3(1, 1, 1); this._getPointer = getPointer.bind(this); this._onPointerDown = onPointerDown.bind(this); this._onPointerHover = onPointerHover.bind(this); this._onPointerMove = onPointerMove.bind(this); this._onPointerUp = onPointerUp.bind(this); this.domElement.addEventListener('pointerdown', this._onPointerDown); this.domElement.addEventListener('pointermove', this._onPointerHover); this.domElement.addEventListener('pointerup', this._onPointerUp); if (autoUpdate) { const fr = new FrameRunner(this.update.bind(this)); this.on.built.add(fr.startup, fr); this.on.destroyed.add(fr.shutdown, fr); } } // updateMatrixWorld updates key transformation variables update() { if (this.object !== -1) { this._parentPosition.set(0, 0, 0); this._parentScale.set(1, 1, 1); this._parentQuaternion.set(0, 0, 0, 1); this.worldPosition.copy(this.object_transform.position); this.worldQuaternion.copy(this.object_transform.rotation); this._worldScale.copy(this.object_transform.scale); this._parentQuaternionInv.copy(this._parentQuaternion).invert(); this._worldQuaternionInv.copy(this.worldQuaternion).invert(); } this.camera.updateMatrixWorld(); this.camera.matrixWorld.decompose(this.cameraPosition, this.cameraQuaternion, this._cameraScale); this.eye.copy(this.cameraPosition).sub(this.worldPosition).normalize(); super.update(); } pointerHover(pointer) { if (this.object === -1 || this.dragging === true) return; _raycaster.setFromCamera(pointer, this.camera); const intersect = intersectObjectWithRay(this._gizmo.picker[this.mode], _raycaster); if (intersect) { this.axis = intersect.node.name; } else { this.axis = null; } } pointerDown(pointer) { if (this.object === -1 || this.dragging === true || pointer.button !== 0) return; const ot = this.object_transform; if (this.axis !== null) { _raycaster.setFromCamera(pointer, this.camera); const planeIntersect = intersectObjectWithRay(this._plane, _raycaster, true); if (planeIntersect) { this._positionStart.copy(ot.position); this._quaternionStart.copy(ot.rotation); this._scaleStart.copy(ot.scale); this.worldPositionStart.copy(ot.position); this.worldQuaternionStart.copy(ot.rotation); this._worldScaleStart.copy(ot.scale); this.pointStart.copy(planeIntersect.contact.position).sub(this.worldPositionStart); } this.dragging = true; _mouseDownEvent.mode = this.mode; this.dispatchEvent(_mouseDownEvent); } } /** * * @param {{type:string}} event */ dispatchEvent(event) { this.entity.sendEvent(event.type, event); } pointerMove(pointer) { const axis = this.axis; const mode = this.mode; const object = this.object; let space = this.space; if (mode === TransformMode.Scale) { space = 'local'; } else if (axis === 'E' || axis === 'XYZE' || axis === 'XYZ') { space = 'world'; } if (object === -1 || axis === null || this.dragging === false || pointer.button !== -1) return; const object_transform = this.object_transform; _raycaster.setFromCamera(pointer, this.camera); const planeIntersect = intersectObjectWithRay(this._plane, _raycaster, true); if (!planeIntersect) return; this.pointEnd.copy(planeIntersect.contact.position).sub(this.worldPositionStart); if (mode === TransformMode.Translate) { // Apply translate this._offset.copy(this.pointEnd).sub(this.pointStart); if (space === 'local' && axis !== 'XYZ') { this._offset.applyQuaternion(this._worldQuaternionInv); } if (axis.indexOf('X') === -1) this._offset.x = 0; if (axis.indexOf('Y') === -1) this._offset.y = 0; if (axis.indexOf('Z') === -1) this._offset.z = 0; if (space === 'local' && axis !== 'XYZ') { this._offset.applyQuaternion(this._quaternionStart).divide(this._parentScale); } else { this._offset.applyQuaternion(this._parentQuaternionInv).divide(this._parentScale); } _tempTransform.position.copy(this._offset); _tempTransform.position.add(this._positionStart); // Apply translation snap if (this.translationSnap) { if (space === 'local') { _tempTransform.position.applyQuaternion(_tempQuaternion.copy(this._quaternionStart).invert()); if (axis.search('X') !== -1) { _tempTransform.position.setX(Math.round(object.position.x / this.translationSnap) * this.translationSnap); } if (axis.search('Y') !== -1) { _tempTransform.position.setY(Math.round(object.position.y / this.translationSnap) * this.translationSnap); } if (axis.search('Z') !== -1) { _tempTransform.position.setZ(Math.round(object.position.z / this.translationSnap) * this.translationSnap); } _tempTransform.position.applyQuaternion(this._quaternionStart); } if (space === 'world') { if (object.parent) { _tempTransform.position.add(_tempVector.setFromMatrixPosition(object.parent.matrixWorld)); } if (axis.search('X') !== -1) { _tempTransform.position.setX(Math.round(object.position.x / this.translationSnap) * this.translationSnap); } if (axis.search('Y') !== -1) { _tempTransform.position.setY(Math.round(object.position.y / this.translationSnap) * this.translationSnap); } if (axis.search('Z') !== -1) { _tempTransform.position.setZ(Math.round(object.position.z / this.translationSnap) * this.translationSnap); } if (object.parent) { _tempTransform.position.sub(_tempVector.setFromMatrixPosition(object.parent.matrixWorld)); } } } // write object_transform.position.copy(_tempTransform.position); } else if (mode === TransformMode.Scale) { if (axis.search('XYZ') !== -1) { let d = this.pointEnd.length() / this.pointStart.length(); if (this.pointEnd.dot(this.pointStart) < 0) d *= -1; _tempVector2.set(d, d, d); } else { _tempVector.copy(this.pointStart); _tempVector2.copy(this.pointEnd); _tempVector.applyQuaternion(this._worldQuaternionInv); _tempVector2.applyQuaternion(this._worldQuaternionInv); _tempVector2.divide(_tempVector); if (axis.search('X') === -1) { _tempVector2.x = 1; } if (axis.search('Y') === -1) { _tempVector2.y = 1; } if (axis.search('Z') === -1) { _tempVector2.z = 1; } } // Apply scale _tempTransform.scale.copy(this._scaleStart); _tempTransform.scale.multiply(_tempVector2); // console.log(this._scaleStart,_tempVector2,_tempVector); if (this.scaleSnap) { if (axis.search('X') !== -1) { _tempTransform.scale.setX(Math.round(_tempTransform.scale.x / this.scaleSnap) * this.scaleSnap || this.scaleSnap); } if (axis.search('Y') !== -1) { _tempTransform.scale.setY(Math.round(_tempTransform.scale.y / this.scaleSnap) * this.scaleSnap || this.scaleSnap); } if (axis.search('Z') !== -1) { _tempTransform.scale.setZ(Math.round(_tempTransform.scale.z / this.scaleSnap) * this.scaleSnap || this.scaleSnap); } } object_transform.scale.copy(_tempTransform.scale); } else if (mode === TransformMode.Rotate) { this._offset.copy(this.pointEnd).sub(this.pointStart); const ROTATION_SPEED = 20 / this.worldPosition.distanceTo(_tempVector.setFromMatrixPosition(this.camera.matrixWorld)); if (axis === 'E') { this.rotationAxis.copy(this.eye); this.rotationAngle = this.pointEnd.angleTo(this.pointStart); this._startNorm.copy(this.pointStart).normalize(); this._endNorm.copy(this.pointEnd).normalize(); this.rotationAngle *= (this._endNorm.cross(this._startNorm).dot(this.eye) < 0 ? 1 : -1); } else if (axis === 'XYZE') { this.rotationAxis.copy(this._offset).cross(this.eye).normalize(); this.rotationAngle = this._offset.dot(_tempVector.copy(this.rotationAxis).cross(this.eye)) * ROTATION_SPEED; } else if (axis === 'X' || axis === 'Y' || axis === 'Z') { this.rotationAxis.copy(_unit[axis]); _tempVector.copy(_unit[axis]); if (space === 'local') { _tempVector.applyQuaternion(this.worldQuaternion); } this.rotationAngle = this._offset.dot(_tempVector.cross(this.eye).normalize()) * ROTATION_SPEED; } // Apply rotation snap if (this.rotationSnap) this.rotationAngle = Math.round(this.rotationAngle / this.rotationSnap) * this.rotationSnap; // Apply rotate if (space === 'local' && axis !== 'E' && axis !== 'XYZE') { _tempTransform.rotation.copy(this._quaternionStart); _tempTransform.rotation.multiply(_tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)).normalize(); } else { this.rotationAxis.applyQuaternion(this._parentQuaternionInv); _tempTransform.rotation.copy(_tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)); _tempTransform.rotation.multiply(this._quaternionStart); _tempTransform.rotation.normalize(); } object_transform.rotation.copy(_tempTransform.rotation); } this.dispatchEvent(_changeEvent); this.dispatchEvent(_objectChangeEvent); } pointerUp(pointer) { if (pointer.button !== 0) return; if (this.dragging && (this.axis !== null)) { _mouseUpEvent.mode = this.mode; this.dispatchEvent(_mouseUpEvent); } this.dragging = false; this.axis = null; } dispose() { this.domElement.removeEventListener('pointerdown', this._onPointerDown); this.domElement.removeEventListener('pointermove', this._onPointerHover); this.domElement.removeEventListener('pointermove', this._onPointerMove); this.domElement.removeEventListener('pointerup', this._onPointerUp); } /** * Set current object * @param {number} entity * @returns {TransformControls} */ attach(entity) { assert.isNonNegativeInteger(entity, 'entity'); this.object = entity; this.visible = true; return this; } /** * * @returns {Transform|undefined} */ get object_transform() { const ecd = this.entity.dataset; return ecd.getComponent(this.object, Transform); } // Detatch from object detach() { this.object = -1; this.visible = false; this.axis = null; return this; } reset() { if (!this.enabled) return; if (this.dragging) { this.object_transform.position.copy(this._positionStart); this.object_transform.rotation.copy(this._quaternionStart); this.object_transform.scale.copy(this._scaleStart); this.dispatchEvent(_changeEvent); this.dispatchEvent(_objectChangeEvent); this.pointStart.copy(this.pointEnd); } } getRaycaster() { return _raycaster; } // TODO: deprecate getMode() { return this.mode; } setMode(mode) { this.mode = mode; } setTranslationSnap(translationSnap) { this.translationSnap = translationSnap; } setRotationSnap(rotationSnap) { this.rotationSnap = rotationSnap; } setScaleSnap(scaleSnap) { this.scaleSnap = scaleSnap; } setSize(size) { this.size = size; } setSpace(space) { this.space = space; } } // mouse / touch event handlers function getPointer(event) { if (this.domElement.ownerDocument.pointerLockElement) { return { x: 0, y: 0, button: event.button }; } else { const rect = this.domElement.getBoundingClientRect(); return { x: (event.clientX - rect.left) / rect.width * 2 - 1, y: -(event.clientY - rect.top) / rect.height * 2 + 1, button: event.button }; } } function onPointerHover(event) { if (!this.enabled) return; switch (event.pointerType) { case 'mouse': case 'pen': this.pointerHover(this._getPointer(event)); break; } } function onPointerDown(event) { if (!this.enabled) return; if (!document.pointerLockElement) { this.domElement.setPointerCapture(event.pointerId); } this.domElement.addEventListener('pointermove', this._onPointerMove); this.pointerHover(this._getPointer(event)); this.pointerDown(this._getPointer(event)); } function onPointerMove(event) { if (!this.enabled) return; this.pointerMove(this._getPointer(event)); } function onPointerUp(event) { if (!this.enabled) return; this.domElement.releasePointerCapture(event.pointerId); this.domElement.removeEventListener('pointermove', this._onPointerMove); this.pointerUp(this._getPointer(event)); } /** * * @param {EntityNode} object * @param {Raycaster} raycaster * @param {boolean} [includeInvisible] * @returns {boolean|{contact:SurfacePoint3,entity:number, node:EntityNode}} */ function intersectObjectWithRay(object, raycaster, includeInvisible = false) { /** * * @type {{contact:SurfacePoint3,entity:number, node:EntityNode}[]} */ const contacts = []; const ray = raycaster.ray; const ray_array = [ ray.origin.x, ray.origin.y, ray.origin.z, ray.direction.x, ray.direction.y, ray.direction.z ]; object.traverse(node => { const sg = node.entity.getComponent(ShadedGeometry); const transform = node.transform; if (!sg) { return; } if (!sg.getFlag(ShadedGeometryFlags.Visible) && !includeInvisible) { return; } const contact = new SurfacePoint3(); if (sg.query_raycast_nearest(contact, ray_array, transform.matrix)) { contacts.push({ contact, entity: node.entity.id, node }); } }); if (contacts.length <= 0) { return false; } // sort contacts by distance contacts.sort((a, b) => { return a.contact.position.distanceSqrTo(ray.origin) - b.contact.position.distanceSqrTo(ray.origin); }); return contacts[0]; } export { TransformControls };