@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
825 lines (516 loc) • 23.3 kB
JavaScript
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 };