UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

676 lines (673 loc) 22.4 kB
import { math } from '../../core/math/math.js'; import { Color } from '../../core/math/color.js'; import { Quat } from '../../core/math/quat.js'; import { Vec3 } from '../../core/math/vec3.js'; import { Ray } from '../../core/shape/ray.js'; import { Plane } from '../../core/shape/plane.js'; import { PROJECTION_PERSPECTIVE } from '../../scene/constants.js'; import { COLOR_GRAY, COLOR_BLUE, COLOR_GREEN, COLOR_RED, color4from3 } from './color.js'; import { Gizmo } from './gizmo.js'; import { Debug } from '../../core/debug.js'; /** * @import { Shape } from './shape/shape.js' * @import { CameraComponent } from '../../framework/components/camera/component.js' * @import { Layer } from '../../scene/layer.js' * @import { MeshInstance } from '../../scene/mesh-instance.js' * @import { GizmoAxis, GizmoDragMode } from './constants.js' */ /** * @typedef {object} GizmoTheme * @property {{ [K in 'x' | 'y' | 'z' | 'f' | 'xyz']: Color }} shapeBase - The axis colors. * @property {{ [K in 'x' | 'y' | 'z' | 'f' | 'xyz']: Color }} shapeHover - The hover colors. * @property {{ [K in 'x' | 'y' | 'z']: Color }} guideBase - The guide line colors. * @property {number} guideOcclusion - The guide occlusion value. Defaults to 0.8. * @property {Color} disabled - The disabled color. */ // temporary variables const v1 = new Vec3(); const v2 = new Vec3(); const point = new Vec3(); const ray = new Ray(); const plane = new Plane(); const color = new Color(); // constants const AXES = /** @type {('x' | 'y' | 'z')[]} */ [ 'x', 'y', 'z' ]; /** * The base class for all transform gizmos. * * @category Gizmo */ class TransformGizmo extends Gizmo { static{ /** * Fired when when the transformation has started. * * @event * @example * const gizmo = new pc.TransformGizmo(camera, layer); * gizmo.on('transform:start', () => { * console.log('Transformation started'); * }); */ this.EVENT_TRANSFORMSTART = 'transform:start'; } static{ /** * Fired during the transformation. * * @event * @example * const gizmo = new pc.TransformGizmo(camera, layer); * gizmo.on('transform:move', (pointDelta, angleDelta) => { * console.log('Transformation moved by ${pointDelta} (angle: ${angleDelta})'); * }); */ this.EVENT_TRANSFORMMOVE = 'transform:move'; } static{ /** * Fired when when the transformation has ended. * * @event * @example * const gizmo = new pc.TransformGizmo(camera, layer); * gizmo.on('transform:end', () => { * console.log('Transformation ended'); * }); */ this.EVENT_TRANSFORMEND = 'transform:end'; } /** * Creates a new TransformGizmo object. * * @param {CameraComponent} camera - The camera component. * @param {Layer} layer - The render layer. * @param {string} [name] - The name of the gizmo. * @example * const gizmo = new pc.TransformGizmo(camera, layer); */ constructor(camera, layer, name = 'gizmo:transform'){ super(camera, layer, name), /** * Internal theme. * * @type {GizmoTheme} * @protected */ this._theme = { shapeBase: { x: COLOR_RED.clone(), y: COLOR_GREEN.clone(), z: COLOR_BLUE.clone(), xyz: new Color(0.8, 0.8, 0.8, 1), f: new Color(0.8, 0.8, 0.8, 1) }, shapeHover: { x: new Color().lerp(COLOR_RED, Color.WHITE, 0.75), y: new Color().lerp(COLOR_GREEN, Color.WHITE, 0.75), z: new Color().lerp(COLOR_BLUE, Color.WHITE, 0.75), xyz: Color.WHITE.clone(), f: Color.WHITE.clone() }, guideBase: { x: COLOR_RED.clone(), y: COLOR_GREEN.clone(), z: COLOR_BLUE.clone() }, guideOcclusion: 0.8, disabled: COLOR_GRAY.clone() }, /** * Internal gizmo starting rotation in world space. * * @type {Vec3} * @protected */ this._rootStartPos = new Vec3(), /** * Internal gizmo starting rotation in world space. * * @type {Quat} * @protected */ this._rootStartRot = new Quat(), /** * Internal object containing the gizmo shapes to render. * * @type {{ [key in GizmoAxis]?: Shape }} * @protected */ this._shapes = {}, /** * Internal mapping of mesh instances to gizmo shapes. * * @type {Map<MeshInstance, Shape>} * @private */ this._shapeMap = new Map(), /** * Internal currently hovered axes * * @type {Set<GizmoAxis>} * @private */ this._hovering = new Set(), /** * Internal currently hovered axis. * * @type {GizmoAxis | ''} * @private */ this._hoverAxis = '', /** * Internal state of if currently hovered shape is a plane. * * @type {boolean} * @private */ this._hoverIsPlane = false, /** * Internal currently selected axis. * * @type {GizmoAxis | ''} * @protected */ this._selectedAxis = '', /** * Internal state of if currently selected shape is a plane. * * @type {boolean} * @protected */ this._selectedIsPlane = false, /** * Internal selection starting coordinates in world space. * * @type {Vec3} * @protected */ this._selectionStartPoint = new Vec3(), /** * Internal state for if snapping is enabled. Defaults to false. * * @type {boolean} * @private */ this._snap = false, /** * Snapping increment. Defaults to 1. * * @type {number} */ this.snapIncrement = 1, /** * Whether to hide the shapes when dragging. Defaults to 'selected'. * * @type {GizmoDragMode} */ this.dragMode = 'selected'; this.on(Gizmo.EVENT_POINTERDOWN, (x, y, meshInstance)=>{ const shape = this._shapeMap.get(meshInstance); if (shape?.disabled) { return; } if (this._dragging) { return; } if (!meshInstance) { return; } this._hoverAxis = ''; this._hoverIsPlane = false; this._selectedAxis = this._getAxis(meshInstance); this._selectedIsPlane = this._getIsPlane(meshInstance); this._rootStartPos.copy(this.root.getLocalPosition()); this._rootStartRot.copy(this.root.getRotation()); const point = this._screenToPoint(x, y); this._selectionStartPoint.copy(point); this.fire(TransformGizmo.EVENT_TRANSFORMSTART, point, x, y); }); this.on(Gizmo.EVENT_POINTERMOVE, (x, y, meshInstance)=>{ const shape = this._shapeMap.get(meshInstance); if (shape?.disabled) { return; } this._hover(meshInstance); if (!this._dragging) { return; } const point = this._screenToPoint(x, y); this.fire(TransformGizmo.EVENT_TRANSFORMMOVE, point, x, y); }); this.on(Gizmo.EVENT_POINTERUP, (_x, _y, meshInstance)=>{ this._hover(meshInstance); if (!this._dragging) { return; } if (meshInstance) { this._hoverAxis = this._selectedAxis; this._hoverIsPlane = this._selectedIsPlane; } this._selectedAxis = ''; this._selectedIsPlane = false; this.fire(TransformGizmo.EVENT_TRANSFORMEND); }); this.on(Gizmo.EVENT_NODESDETACH, ()=>{ this.snap = false; this._hoverAxis = ''; this._hoverIsPlane = false; this._hover(); this.fire(Gizmo.EVENT_POINTERUP); }); } /** * Sets whether snapping is enabled. Defaults to false. * * @type {boolean} */ set snap(value) { this._snap = this.enabled && value; } /** * Gets whether snapping is enabled. Defaults to false. * * @type {boolean} */ get snap() { return this._snap; } /** * Gets the current theme for the gizmo. * * @type {GizmoTheme} */ get theme() { return this._theme; } /** * @type {Color} * @deprecated Use {@link TransformGizmo#setTheme} instead. * @ignore */ set xAxisColor(value) { this.setTheme({ shapeBase: { x: value }, shapeHover: { x: color4from3(value, this.colorAlpha) }, guideBase: { x: value }, guideOcclusion: 1 }); } /** * @type {Color} * @deprecated Use {@link TransformGizmo#theme} instead. * @ignore */ get xAxisColor() { return this._theme.shapeBase.x; } /** * @type {Color} * @deprecated Use {@link TransformGizmo#setTheme} instead. * @ignore */ set yAxisColor(value) { this.setTheme({ shapeBase: { y: value }, shapeHover: { y: color4from3(value, this.colorAlpha) }, guideBase: { y: value }, guideOcclusion: 1 }); } /** * @type {Color} * @deprecated Use {@link TransformGizmo#theme} instead. * @ignore */ get yAxisColor() { return this._theme.shapeBase.y; } /** * @type {Color} * @deprecated Use {@link TransformGizmo#setTheme} instead. * @ignore */ set zAxisColor(value) { this.setTheme({ shapeBase: { z: value }, shapeHover: { z: color4from3(value, this.colorAlpha) }, guideBase: { z: value }, guideOcclusion: 1 }); } /** * @type {Color} * @deprecated Use {@link TransformGizmo#theme} instead. * @ignore */ get zAxisColor() { return this._theme.shapeBase.z; } /** * @type {number} * @deprecated Use {@link TransformGizmo#setTheme} instead. * @ignore */ set colorAlpha(value) { this.setTheme({ shapeHover: { x: color4from3(this._theme.shapeHover.x, value), y: color4from3(this._theme.shapeHover.y, value), z: color4from3(this._theme.shapeHover.z, value), xyz: color4from3(this._theme.shapeHover.xyz, value), f: color4from3(this._theme.shapeHover.f, value) } }); } /** * @type {number} * @deprecated Use {@link TransformGizmo#theme} instead. * @ignore */ get colorAlpha() { return (this._theme.shapeHover.x.a + this._theme.shapeHover.y.a + this._theme.shapeHover.z.a + this._theme.shapeHover.xyz.a + this._theme.shapeHover.f.a) / 5; } /** * @type {boolean} * @protected */ get _dragging() { return !this._hoverAxis && !!this._selectedAxis; } /** * @param {MeshInstance} [meshInstance] - The mesh instance. * @returns {GizmoAxis | ''} - The axis. * @private */ _getAxis(meshInstance) { if (!meshInstance) { return ''; } return /** @type {GizmoAxis | ''} */ meshInstance.node.name.split(':')[1]; } /** * @param {MeshInstance} [meshInstance] - The mesh instance. * @returns {boolean} - Whether the mesh instance is a plane. * @private */ _getIsPlane(meshInstance) { if (!meshInstance) { return false; } return meshInstance.node.name.indexOf('plane') !== -1; } /** * @param {MeshInstance} [meshInstance] - The mesh instance. * @private */ _hover(meshInstance) { if (this._dragging) { return; } // track changes const remove = new Set(this._hovering); let changed = false; const add = (/** @type {GizmoAxis} */ axis)=>{ if (remove.has(axis)) { remove.delete(axis); } else { this._hovering.add(axis); this._shapes[axis]?.hover(true); changed = true; } }; // determine which axis is hovered this._hoverAxis = this._getAxis(meshInstance); this._hoverIsPlane = this._getIsPlane(meshInstance); // add shapes that are hovered if (this._hoverAxis) { if (this._hoverAxis === 'xyz') { add('x'); add('y'); add('z'); add('xyz'); } else if (this._hoverIsPlane) { switch(this._hoverAxis){ case 'x': add('y'); add('z'); add('yz'); break; case 'y': add('x'); add('z'); add('xz'); break; case 'z': add('x'); add('y'); add('xy'); break; } } else { add(this._hoverAxis); } } // unhover removed shapes for (const axis of remove){ this._hovering.delete(axis); this._shapes[axis]?.hover(false); changed = true; } if (changed) { this._renderUpdate = true; } } /** * @param {Vec3} mouseWPos - The mouse world position. * @returns {Ray} - The ray. * @protected */ _createRay(mouseWPos) { if (this._camera.projection === PROJECTION_PERSPECTIVE) { ray.origin.copy(this._camera.entity.getPosition()); ray.direction.sub2(mouseWPos, ray.origin).normalize(); return ray; } const orthoDepth = this._camera.farClip - this._camera.nearClip; ray.origin.sub2(mouseWPos, v1.copy(this._camera.entity.forward).mulScalar(orthoDepth)); ray.direction.copy(this._camera.entity.forward); return ray; } /** * @param {string} axis - The axis to create the plane for. * @param {boolean} isFacing - Whether the axis is facing the camera. * @param {boolean} isLine - Whether the axis is a line. * @returns {Plane} - The plane. * @protected */ _createPlane(axis, isFacing, isLine) { const facingDir = v1.copy(this.facingDir); const normal = plane.normal.set(0, 0, 0); if (isFacing) { // set plane normal to face camera normal.copy(this._camera.entity.forward).mulScalar(-1); } else { // set plane normal based on axis normal[axis] = 1; this._rootStartRot.transformVector(normal, normal); if (isLine) { // set plane normal to face camera but keep normal perpendicular to axis v2.cross(normal, facingDir).normalize(); normal.cross(v2, normal).normalize(); } } return plane.setFromPointNormal(this._rootStartPos, normal); } /** * @param {string} axis - The axis * @param {Vec3} dir - The direction * @returns {Vec3} - The direction * @protected */ _dirFromAxis(axis, dir) { if (axis === 'f') { dir.copy(this._camera.entity.forward).mulScalar(-1); } else { dir.set(0, 0, 0); dir[axis] = 1; } return dir; } /** * @param {Vec3} point - The point to project. * @param {string} axis - The axis to project to. * @protected */ _projectToAxis(point, axis) { // set normal to axis and project position from plane onto normal v1.set(0, 0, 0); v1[axis] = 1; point.copy(v1.mulScalar(v1.dot(point))); // set other axes to zero (floating point fix) const v = point[axis]; point.set(0, 0, 0); point[axis] = v; } /** * @param {number} x - The x coordinate. * @param {number} y - The y coordinate. * @param {boolean} isFacing - Whether the axis is facing the camera. * @param {boolean} isLine - Whether the axis is a line. * @returns {Vec3} The point (space is {@link TransformGizmo#coordSpace}). * @protected */ _screenToPoint(x, y, isFacing = false, isLine = false) { const mouseWPos = this._camera.screenToWorld(x, y, 1); const axis = this._selectedAxis; const ray = this._createRay(mouseWPos); const plane = this._createPlane(axis, isFacing, isLine); if (!plane.intersectsRay(ray, point)) { return point; } return point; } /** * @param {Vec3} pos - The position. * @param {Quat} rot - The rotation. * @param {GizmoAxis | ''} activeAxis - The active axis. * @param {boolean} activeIsPlane - Whether the active axis is a plane. * @protected */ _drawGuideLines(pos, rot, activeAxis, activeIsPlane) { for (const axis of AXES){ if (activeAxis === 'xyz') { this._drawSpanLine(pos, rot, axis); continue; } if (activeIsPlane) { if (axis !== activeAxis) { this._drawSpanLine(pos, rot, axis); } } else { if (axis === activeAxis) { this._drawSpanLine(pos, rot, axis); } } } } /** * @param {Vec3} pos - The position. * @param {Quat} rot - The rotation. * @param {'x' | 'y' | 'z'} axis - The axis. * @protected */ _drawSpanLine(pos, rot, axis) { const dir = this._dirFromAxis(axis, v1); const base = this._theme.guideBase[axis]; const from = v1.copy(dir).mulScalar(this._camera.farClip - this._camera.nearClip); const to = v2.copy(from).mulScalar(-1); rot.transformVector(from, from).add(pos); rot.transformVector(to, to).add(pos); if (this._theme.guideOcclusion < 1) { const occluded = color.copy(base); occluded.a *= 1 - this._theme.guideOcclusion; this._app.drawLine(from, to, occluded, false, this._layer); } if (base.a !== 0) { this._app.drawLine(from, to, base, true); } } /** * @protected */ _createTransform() { // shapes for(const key in this._shapes){ const shape = this._shapes[key]; this.root.addChild(shape.entity); this.intersectShapes.push(shape); for(let i = 0; i < shape.meshInstances.length; i++){ this._shapeMap.set(shape.meshInstances[i], shape); } } } /** * Set the shape to be enabled or disabled. * * @param {GizmoAxis | 'face'} shapeAxis - The shape axis. * @param {boolean} enabled - The enabled state of shape. */ enableShape(shapeAxis, enabled) { if (shapeAxis === 'face') { Debug.deprecated('"face" literal is deprecated use "f" literal instead'); shapeAxis = 'f'; } const shape = this._shapes[shapeAxis]; if (!shape) { return; } shape.disabled = !enabled; } /** * Get the enabled state of the shape. * * @param {GizmoAxis | 'face'} shapeAxis - The shape axis. Can be: * @returns {boolean} - Then enabled state of the shape */ isShapeEnabled(shapeAxis) { if (shapeAxis === 'face') { Debug.deprecated('"face" literal is deprecated use "f" literal instead'); shapeAxis = 'f'; } const shape = this._shapes[shapeAxis]; if (!shape) { return false; } return !shape.disabled; } /** * Sets the theme or partial theme for the gizmo. * * @param {{ [K in keyof GizmoTheme]?: Partial<GizmoTheme[K]> }} partial - The partial theme to set. */ setTheme(partial) { const theme = { ...this._theme, ...partial }; if (typeof theme !== 'object' || typeof theme.shapeBase !== 'object' || typeof theme.shapeHover !== 'object' || typeof theme.guideBase !== 'object') { return; } // shape for(const axis in theme.shapeBase){ if (theme.shapeBase[axis] instanceof Color) { this._theme.shapeBase[axis].copy(theme.shapeBase[axis]); } } for(const axis in theme.shapeHover){ if (theme.shapeHover[axis] instanceof Color) { this._theme.shapeHover[axis].copy(theme.shapeHover[axis]); } } // guide for(const axis in theme.guideBase){ if (theme.guideBase[axis] instanceof Color) { this._theme.guideBase[axis].copy(theme.guideBase[axis]); } } if (typeof theme.guideOcclusion === 'number') { this._theme.guideOcclusion = math.clamp(theme.guideOcclusion, 0, 1); } // disabled if (theme.disabled instanceof Color) { this._theme.disabled.copy(theme.disabled); } // update shapes for(const name in this._shapes){ this._shapes[name].hover(!!this._hoverAxis); } } /** * @override */ prerender() { super.prerender(); if (!this.enabled) { return; } const gizmoPos = this.root.getLocalPosition(); const gizmoRot = this.root.getRotation(); const activeAxis = this._hoverAxis || this._selectedAxis; const activeIsPlane = this._hoverIsPlane || this._selectedIsPlane; this._drawGuideLines(gizmoPos, gizmoRot, activeAxis, activeIsPlane); } /** * @override */ destroy() { super.destroy(); for(const key in this._shapes){ this._shapes[key].destroy(); } } } export { TransformGizmo };