UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

686 lines (683 loc) 25.2 kB
import { math } from '../../core/math/math.js'; import { Color } from '../../core/math/color.js'; import { Quat } from '../../core/math/quat.js'; import { Vec2 } from '../../core/math/vec2.js'; import { Vec3 } from '../../core/math/vec3.js'; import { PROJECTION_PERSPECTIVE } from '../../scene/constants.js'; import { ArcShape } from './shape/arc-shape.js'; import { TransformGizmo } from './transform-gizmo.js'; import { MeshLine } from './mesh-line.js'; import { SphereShape } from './shape/sphere-shape.js'; /** * @import { CameraComponent } from '../../framework/components/camera/component.js' * @import { GraphNode } from '../../scene/graph-node.js' * @import { Layer } from '../../scene/layer.js' * @import { GizmoAxis } from './constants.js' */ // temporary variables const point = new Vec3(); const v1 = new Vec3(); const v2 = new Vec3(); const v3 = new Vec3(); const q1 = new Quat(); const q2 = new Quat(); const color = new Color(); // constants const RING_FACING_EPSILON = 1e-4; const UPDATE_EPSILON = 1e-6; const AXES = /** @type {('x' | 'y' | 'z')[]} */ [ 'x', 'y', 'z' ]; /** * The RotateGizmo provides interactive 3D manipulation handles for rotating/reorienting * {@link Entity}s in a {@link Scene}. It creates a visual widget with a draggable ring for each * axis of rotation, plus a fourth ring for rotation in the camera's view plane, allowing precise * control over object orientation through direct manipulation. The gizmo's visual appearance can * be customized away from the defaults as required. * * Note that the gizmo can be driven by both mouse+keyboard and touch input. * * ```javascript * // Create a layer for rendering all gizmos * const gizmoLayer = pc.Gizmo.createLayer(app); * * // Create a rotate gizmo * const gizmo = new pc.RotateGizmo(cameraComponent, gizmoLayer); * * // Create an entity to attach the gizmo to * const entity = new pc.Entity(); * entity.addComponent('render', { * type: 'box' * }); * app.root.addChild(entity); * * // Attach the gizmo to the entity * gizmo.attach([entity]); * ``` * * Relevant Engine API examples: * * - [Rotate Gizmo](https://playcanvas.github.io/#/gizmos/transform-rotate) * - [Editor](https://playcanvas.github.io/#/misc/editor) * * @category Gizmo */ class RotateGizmo extends TransformGizmo { /** * Creates a new RotateGizmo object. Use {@link Gizmo.createLayer} to create the layer * required to display the gizmo. * * @param {CameraComponent} camera - The camera component. * @param {Layer} layer - The layer responsible for rendering the gizmo. * @example * const gizmo = new pc.RotateGizmo(camera, layer); */ constructor(camera, layer){ super(camera, layer, 'gizmo:rotate'), this._shapes = { z: new ArcShape(this._device, { axis: 'z', layers: [ this._layer.id ], rotation: new Vec3(90, 0, 90), defaultColor: this._theme.shapeBase.z, hoverColor: this._theme.shapeHover.z, disabledColor: this._theme.disabled, sectorAngle: 180 }), x: new ArcShape(this._device, { axis: 'x', layers: [ this._layer.id ], rotation: new Vec3(0, 0, -90), defaultColor: this._theme.shapeBase.x, hoverColor: this._theme.shapeHover.x, disabledColor: this._theme.disabled, sectorAngle: 180 }), y: new ArcShape(this._device, { axis: 'y', layers: [ this._layer.id ], rotation: new Vec3(0, 0, 0), defaultColor: this._theme.shapeBase.y, hoverColor: this._theme.shapeHover.y, disabledColor: this._theme.disabled, sectorAngle: 180 }), f: new ArcShape(this._device, { axis: 'f', layers: [ this._layer.id ], defaultColor: this._theme.shapeBase.f, hoverColor: this._theme.shapeHover.f, disabledColor: this._theme.disabled, ringRadius: 0.55 }), xyz: new SphereShape(this._device, { axis: 'xyz', layers: [ this._layer.id ], defaultColor: this._theme.shapeBase.xyz, hoverColor: this._theme.shapeHover.xyz, disabledColor: this._theme.disabled, radius: 0.5 }) }, /** * Internal selection starting angle in world space. * * @type {number} * @private */ this._selectionStartAngle = 0, /** * Internal mapping from each attached node to their starting rotation in local space. * * @type {Map<GraphNode, Quat>} * @private */ this._nodeLocalRotations = new Map(), /** * Internal mapping from each attached node to their starting rotation in world space. * * @type {Map<GraphNode, Quat>} * @private */ this._nodeRotations = new Map(), /** * Internal mapping from each attached node to their offset position from the gizmo. * * @type {Map<GraphNode, Vec3>} * @private */ this._nodeOffsets = new Map(), /** * Internal vector for storing the mouse position in screen space. * * @type {Vec2} * @private */ this._screenPos = new Vec2(), /** * Internal vector for storing the mouse start position in screen space. * * @type {Vec2} * @private */ this._screenStartPos = new Vec2(), /** * Internal vector for the start point of the guide line angle. * * @type {Vec3} * @private */ this._guideAngleStart = new Vec3(), /** * Internal vector for the end point of the guide line angle. * * @type {Vec3} * @private */ this._guideAngleEnd = new Vec3(), /** * Internal copy of facing direction to avoid unnecessary updates. * * @type {Vec3} * @private */ this._facingDir = new Vec3(), /** * @override */ this.snapIncrement = 5, /** * The rotation mode of the gizmo. This can be either: * * - 'absolute': The rotation is calculated based on the mouse displacement relative to the * initial click point. * - 'orbit': The rotation is calculated based on the gizmos position around the center of * rotation. * * @type {'absolute' | 'orbit'} */ this.rotationMode = 'absolute'; this.setTheme({ shapeBase: { xyz: new Color(0, 0, 0, 0) }, shapeHover: { xyz: new Color(1, 1, 1, 0.2) } }); this._createTransform(); this._guideAngleLines = [ new MeshLine(this._app, this._layer), new MeshLine(this._app, this._layer) ]; this._guideAngleLines.forEach((line)=>{ this._app.root.addChild(line.entity); line.entity.enabled = false; }); this.on(TransformGizmo.EVENT_TRANSFORMSTART, (point, x, y)=>{ // store start screen point this._screenPos.set(x, y); this._screenStartPos.set(x, y); // store start angle this._selectionStartAngle = this._calculateArcAngle(point, x, y); // store initial node rotations this._storeNodeRotations(); // store guide points this._storeGuidePoints(); // drag handle for disk (arc <-> circle) this._drag(true); // angle guide lines this._angleGuide(true); }); this.on(TransformGizmo.EVENT_TRANSFORMMOVE, (point, x, y)=>{ const axis = this._selectedAxis; if (!axis) { return; } // update screen point this._screenPos.set(x, y); if (axis === 'xyz') { // calculate angle axis and delta and update node rotations const facingDir = v1.copy(this.facingDir); const delta = v2.copy(point).sub(this._selectionStartPoint); const angleAxis = v1.cross(facingDir, delta).normalize(); const angleDelta = this._screenPos.distance(this._screenStartPos); this._setNodeRotations(axis, angleAxis, angleDelta); } else { // calculate angle axis and delta and update node rotations let angleDelta = this._calculateArcAngle(point, x, y) - this._selectionStartAngle; if (this.snap) { angleDelta = Math.round(angleDelta / this.snapIncrement) * this.snapIncrement; } const angleAxis = this._dirFromAxis(axis, v1); this._setNodeRotations(axis, angleAxis, angleDelta); // update guide points and show angle guide this._updateGuidePoints(angleDelta); this._angleGuide(true); } }); this.on(TransformGizmo.EVENT_TRANSFORMEND, ()=>{ // show all shapes this._drag(false); // hide angle guide this._angleGuide(false); }); this.on(TransformGizmo.EVENT_NODESDETACH, ()=>{ // reset stored rotations and offsets this._nodeLocalRotations.clear(); this._nodeRotations.clear(); this._nodeOffsets.clear(); }); } /** * Sets the XYZ tube radius. * * @type {number} */ set xyzTubeRadius(value) { this._setDiskProp('tubeRadius', value); } /** * Gets the XYZ tube radius. * * @type {number} */ get xyzTubeRadius() { return this._shapes.x.tubeRadius; } /** * Sets the XYZ ring radius. * * @type {number} */ set xyzRingRadius(value) { this._setDiskProp('ringRadius', value); } /** * Gets the XYZ ring radius. * * @type {number} */ get xyzRingRadius() { return this._shapes.x.ringRadius; } /** * Sets the face tube radius. * * @type {number} */ set faceTubeRadius(value) { this._shapes.f.tubeRadius = value; } /** * Gets the face tube radius. * * @type {number} */ get faceTubeRadius() { return this._shapes.f.tubeRadius; } /** * Sets the face ring radius. * * @type {number} */ set faceRingRadius(value) { this._shapes.f.ringRadius = value; } /** * Gets the face ring radius. * * @type {number} */ get faceRingRadius() { return this._shapes.f.ringRadius; } /** * Sets the center radius. * * @type {number} */ set centerRadius(value) { this._shapes.xyz.radius = value; } /** * Gets the center radius. * * @type {number} */ get centerRadius() { return this._shapes.xyz.radius; } /** * Sets the ring tolerance. * * @type {number} */ set ringTolerance(value) { this._setDiskProp('tolerance', value); this._shapes.f.tolerance = value; } /** * Gets the ring tolerance. * * @type {number} */ get ringTolerance() { return this._shapes.x.tolerance; } /** * Sets the angle guide line thickness. * * @type {number} */ set angleGuideThickness(value) { this._guideAngleLines[0].thickness = value; this._guideAngleLines[1].thickness = value; } /** * Gets the angle guide line thickness. * * @type {number} */ get angleGuideThickness() { return this._guideAngleLines[0].thickness; } /** * @type {boolean} * @deprecated Use {@link RotationGizmo#rotationMode} instead. * @ignore */ set orbitRotation(value) { this.rotationMode = value ? 'orbit' : 'absolute'; } /** * @type {boolean} * @deprecated Use {@link RotationGizmo#rotationMode} instead. * @ignore */ get orbitRotation() { return this.rotationMode === 'orbit'; } /** * @param {string} prop - The property. * @param {any} value - The value. * @private */ _setDiskProp(prop, value) { this._shapes.x[prop] = value; this._shapes.y[prop] = value; this._shapes.z[prop] = value; } /** * @private */ _storeGuidePoints() { const gizmoPos = this.root.getLocalPosition(); const axis = this._selectedAxis; const isFacing = axis === 'f'; const scale = isFacing ? this.faceRingRadius : this.xyzRingRadius; this._guideAngleStart.copy(this._selectionStartPoint).sub(gizmoPos).normalize(); this._guideAngleStart.mulScalar(scale); this._guideAngleEnd.copy(this._guideAngleStart); } /** * @param {number} angleDelta - The angle delta. * @private */ _updateGuidePoints(angleDelta) { const axis = this._selectedAxis; const isFacing = axis === 'f'; if (isFacing) { v1.copy(this.facingDir); } else { v1.set(0, 0, 0); v1[axis] = 1; this._rootStartRot.transformVector(v1, v1); } q1.setFromAxisAngle(v1, angleDelta); q1.transformVector(this._guideAngleStart, this._guideAngleEnd); this._renderUpdate = true; } /** * @param {boolean} state - The state. * @private */ _angleGuide(state) { const axis = this._selectedAxis; if (state && this.dragMode !== 'show' && axis !== 'xyz') { const gizmoPos = this.root.getLocalPosition(); const baseColor = this._theme.shapeHover[axis]; const startColor = color.copy(baseColor); startColor.a *= 0.3; this._guideAngleLines[0].draw(gizmoPos, v1.copy(this._guideAngleStart).add(gizmoPos), this._scale, startColor); this._guideAngleLines[1].draw(gizmoPos, v1.copy(this._guideAngleEnd).add(gizmoPos), this._scale, baseColor); this._guideAngleLines[0].entity.enabled = true; this._guideAngleLines[1].entity.enabled = true; } else { this._guideAngleLines[0].entity.enabled = false; this._guideAngleLines[1].entity.enabled = false; } } /** * @private */ _shapesLookAtCamera() { // face shape if (this._camera.projection === PROJECTION_PERSPECTIVE) { const dir = this._camera.entity.getPosition().sub(this.root.getPosition()).normalize(); const elev = Math.atan2(-dir.y, Math.sqrt(dir.x * dir.x + dir.z * dir.z)) * math.RAD_TO_DEG; const azim = Math.atan2(-dir.x, -dir.z) * math.RAD_TO_DEG; this._shapes.f.entity.setEulerAngles(-elev + 90, azim, 0); } else { q1.copy(this._camera.entity.getRotation()).getEulerAngles(v1); this._shapes.f.entity.setEulerAngles(v1); this._shapes.f.entity.rotateLocal(-90, 0, 0); } // axes shapes let angle, dot, sector; const facingDir = v1.copy(this.facingDir); q1.copy(this.root.getRotation()).invert().transformVector(facingDir, facingDir); angle = Math.atan2(facingDir.z, facingDir.y) * math.RAD_TO_DEG; this._shapes.x.entity.setLocalEulerAngles(0, angle - 90, -90); angle = Math.atan2(facingDir.x, facingDir.z) * math.RAD_TO_DEG; this._shapes.y.entity.setLocalEulerAngles(0, angle, 0); angle = Math.atan2(facingDir.y, facingDir.x) * math.RAD_TO_DEG; this._shapes.z.entity.setLocalEulerAngles(90, 0, angle + 90); if (!this._dragging) { dot = facingDir.dot(this.root.right); sector = 1 - Math.abs(dot) > RING_FACING_EPSILON; this._shapes.x.show(sector ? 'sector' : 'ring'); dot = facingDir.dot(this.root.up); sector = 1 - Math.abs(dot) > RING_FACING_EPSILON; this._shapes.y.show(sector ? 'sector' : 'ring'); dot = facingDir.dot(this.root.forward); sector = 1 - Math.abs(dot) > RING_FACING_EPSILON; this._shapes.z.show(sector ? 'sector' : 'ring'); } if (!facingDir.equalsApprox(this._facingDir, UPDATE_EPSILON)) { this._facingDir.copy(facingDir); this._renderUpdate = true; } } /** * @param {boolean} state - The state. * @private */ _drag(state) { for(const axis in this._shapes){ const shape = this._shapes[axis]; if (!(shape instanceof ArcShape)) { continue; } switch(this.dragMode){ case 'show': { break; } case 'hide': { shape.show(state ? axis === this._selectedAxis ? 'ring' : 'none' : 'sector'); continue; } case 'selected': { shape.show(state ? axis === this._selectedAxis ? 'ring' : 'sector' : 'sector'); break; } } } this._renderUpdate = true; } /** * @private */ _storeNodeRotations() { const gizmoPos = this.root.getLocalPosition(); for(let i = 0; i < this.nodes.length; i++){ const node = this.nodes[i]; this._nodeLocalRotations.set(node, node.getLocalRotation().clone()); this._nodeRotations.set(node, node.getRotation().clone()); this._nodeOffsets.set(node, node.getPosition().clone().sub(gizmoPos)); } } /** * @param {GizmoAxis} axis - The axis. * @param {Vec3} angleAxis - The angle axis. * @param {number} angleDelta - The angle delta. * @private */ _setNodeRotations(axis, angleAxis, angleDelta) { const gizmoPos = this.root.getLocalPosition(); // calculate rotation from axis and angle q1.setFromAxisAngle(angleAxis, angleDelta); for(let i = 0; i < this.nodes.length; i++){ const node = this.nodes[i]; if ((axis === 'x' || axis === 'y' || axis === 'z') && this._coordSpace === 'local') { const rot = this._nodeLocalRotations.get(node); if (!rot) { continue; } q2.copy(rot).mul(q1); node.setLocalRotation(q2); } else { const rot = this._nodeRotations.get(node); if (!rot) { continue; } const offset = this._nodeOffsets.get(node); if (!offset) { continue; } v1.copy(offset); q1.transformVector(v1, v1); q2.copy(q1).mul(rot); node.setRotation(q2); node.setPosition(v1.add(gizmoPos)); } } if (this._coordSpace === 'local') { this._updateRotation(); } } /** * @param {number} x - The x coordinate. * @param {number} y - The y coordinate. * @returns {Vec3} The point (space is {@link TransformGizmo#coordSpace}). * @protected */ _screenToPoint(x, y) { const mouseWPos = this._camera.screenToWorld(x, y, 1); const axis = this._selectedAxis; const ray = this._createRay(mouseWPos); const plane = this._createPlane(axis, axis === 'f' || axis === 'xyz', false); if (!plane.intersectsRay(ray, point)) { // if no intersection, try inverting the ray direction ray.direction.mulScalar(-1); const intersection = plane.intersectsRay(ray, point); ray.direction.mulScalar(-1); if (!intersection) { // use gizmo position if ray does not intersect to position angle guide correctly return point.copy(this.root.getLocalPosition()); } } return point; } /** * @param {Vec3} point - The point. * @param {number} x - The x coordinate. * @param {number} y - The y coordinate. * @returns {number} The angle. * @protected */ _calculateArcAngle(point, x, y) { const gizmoPos = this.root.getLocalPosition(); const axis = this._selectedAxis; const plane = this._createPlane(axis, axis === 'f', false); let angle = 0; // arc angle const facingDir = this.facingDir; const facingDot = plane.normal.dot(facingDir); switch(this.rotationMode){ case 'absolute': { this._camera.worldToScreen(gizmoPos, v2); if (axis === 'f' || facingDot > 1 - RING_FACING_EPSILON) { // determine which size of the ring the mouse is on to flip rotation direction v1.set(this._screenStartPos.y >= v2.y ? 1 : -1, this._screenStartPos.x >= v2.x ? -1 : 1, 0).normalize(); } else { // calculate projection vector in world space for rotation axis const projDir = v1.cross(plane.normal, facingDir).normalize(); // convert to screen space this._camera.worldToScreen(projDir.add(gizmoPos), v3); v1.sub2(v3, v2).normalize(); } // angle is dot product with mouse position v2.set(x, y, 0); angle = v1.dot(v2); break; } case 'orbit': { // plane facing camera so based on mouse position around gizmo v1.sub2(point, gizmoPos); switch(axis){ case 'x': { // convert to local space q1.copy(this._rootStartRot).invert().transformVector(v1, v1); angle = Math.atan2(v1.z, v1.y) * math.RAD_TO_DEG; break; } case 'y': { // convert to local space q1.copy(this._rootStartRot).invert().transformVector(v1, v1); angle = Math.atan2(v1.x, v1.z) * math.RAD_TO_DEG; break; } case 'z': { // convert to local space q1.copy(this._rootStartRot).invert().transformVector(v1, v1); angle = Math.atan2(v1.y, v1.x) * math.RAD_TO_DEG; break; } case 'f': { // convert to camera space q1.copy(this._camera.entity.getRotation()).invert().transformVector(v1, v1); angle = Math.sign(facingDot) * Math.atan2(v1.y, v1.x) * math.RAD_TO_DEG; break; } } // intersection point can be behind camera, so need to check to flip angle delta const dir = v1.sub2(point, this._camera.entity.getPosition()).normalize(); const dot = dir.dot(this._camera.entity.forward); if (dot < 0) { angle += 180; } break; } } return angle; } /** * @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. * @override */ _drawGuideLines(pos, rot, activeAxis, activeIsPlane) { for (const axis of AXES){ if (activeAxis === 'xyz') { continue; } if (activeIsPlane) { if (axis !== activeAxis) { this._drawSpanLine(pos, rot, axis); } } else { if (axis === activeAxis) { this._drawSpanLine(pos, rot, axis); } } } } /** * @override */ prerender() { super.prerender(); if (!this.enabled) { return; } this._shapesLookAtCamera(); } /** * @override */ destroy() { this._guideAngleLines.forEach((line)=>line.destroy()); super.destroy(); } } export { RotateGizmo };