playcanvas
Version:
PlayCanvas WebGL game engine
535 lines (532 loc) • 17.5 kB
JavaScript
import { Vec3 } from '../../core/math/vec3.js';
import { Quat } from '../../core/math/quat.js';
import { TransformGizmo } from './transform-gizmo.js';
import { PlaneShape } from './shape/plane-shape.js';
import { ArrowShape } from './shape/arrow-shape.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 v1 = new Vec3();
const v2 = new Vec3();
const point = new Vec3();
const delta = new Vec3();
const q = new Quat();
// constants
const GLANCE_EPSILON = 0.01;
const AXES = /** @type {('x' | 'y' | 'z')[]} */ [
'x',
'y',
'z'
];
/**
* The TranslateGizmo provides interactive 3D manipulation handles for translating/moving
* {@link Entity}s in a {@link Scene}. It creates a visual widget with arrows along the X, Y
* and Z axes, planes at their intersections, and a center sphere, allowing precise control over
* object positioning 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 translate gizmo
* const gizmo = new pc.TranslateGizmo(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:
*
* - [Translate Gizmo](https://playcanvas.github.io/#/gizmos/transform-translate)
* - [Editor](https://playcanvas.github.io/#/misc/editor)
*
* @category Gizmo
*/ class TranslateGizmo extends TransformGizmo {
/**
* Creates a new TranslateGizmo 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.TranslateGizmo(camera, layer);
*/ constructor(camera, layer){
super(camera, layer, 'gizmo:translate'), this._shapes = {
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
}),
yz: new PlaneShape(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,
depth: 1
}),
xz: new PlaneShape(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,
depth: 1
}),
xy: new PlaneShape(this._device, {
axis: 'z',
layers: [
this._layer.id
],
rotation: new Vec3(90, 0, 0),
defaultColor: this._theme.shapeBase.z,
hoverColor: this._theme.shapeHover.z,
disabledColor: this._theme.disabled,
depth: 1
}),
x: new ArrowShape(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
}),
y: new ArrowShape(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
}),
z: new ArrowShape(this._device, {
axis: 'z',
layers: [
this._layer.id
],
rotation: new Vec3(90, 0, 0),
defaultColor: this._theme.shapeBase.z,
hoverColor: this._theme.shapeHover.z,
disabledColor: this._theme.disabled
})
}, /**
* Internal mapping from each attached node to their starting position in local space.
*
* @type {Map<GraphNode, Vec3>}
* @private
*/ this._nodeLocalPositions = new Map(), /**
* Internal mapping from each attached node to their starting position in world space.
*
* @type {Map<GraphNode, Vec3>}
* @private
*/ this._nodePositions = new Map(), /**
* @override
*/ this.snapIncrement = 1, /**
* Flips the planes to face the camera.
*
* @type {boolean}
*/ this.flipPlanes = true;
this._createTransform();
this.on(TransformGizmo.EVENT_TRANSFORMSTART, ()=>{
// store the initial positions of the nodes
this._storeNodePositions();
// hide shapes that are not selected
this._drag(true);
});
this.on(TransformGizmo.EVENT_TRANSFORMMOVE, (point)=>{
// calculate translate delta and update node positions
const translateDelta = delta.copy(point).sub(this._selectionStartPoint);
if (this.snap) {
translateDelta.mulScalar(1 / this.snapIncrement);
translateDelta.round();
translateDelta.mulScalar(this.snapIncrement);
}
this._setNodePositions(translateDelta);
});
this.on(TransformGizmo.EVENT_TRANSFORMEND, ()=>{
// show all shapes
this._drag(false);
});
this.on(TransformGizmo.EVENT_NODESDETACH, ()=>{
// reset stored positions
this._nodeLocalPositions.clear();
this._nodePositions.clear();
});
}
/**
* Sets the axis gap.
*
* @type {number}
*/ set axisGap(value) {
this._setArrowProp('gap', value);
}
/**
* Gets the axis gap.
*
* @type {number}
*/ get axisGap() {
return this._shapes.x.gap;
}
/**
* Sets the axis line thickness.
*
* @type {number}
*/ set axisLineThickness(value) {
this._setArrowProp('lineThickness', value);
}
/**
* Gets the axis line thickness.
*
* @type {number}
*/ get axisLineThickness() {
return this._shapes.x.lineThickness;
}
/**
* Sets the axis line length.
*
* @type {number}
*/ set axisLineLength(value) {
this._setArrowProp('lineLength', value);
}
/**
* Gets the axis line length.
*
* @type {number}
*/ get axisLineLength() {
return this._shapes.x.lineLength;
}
/**
* Sets the axis line tolerance.
*
* @type {number}
*/ set axisLineTolerance(value) {
this._setArrowProp('tolerance', value);
}
/**
* Gets the axis line tolerance.
*
* @type {number}
*/ get axisLineTolerance() {
return this._shapes.x.tolerance;
}
/**
* Sets the arrow thickness.
*
* @type {number}
*/ set axisArrowThickness(value) {
this._setArrowProp('arrowThickness', value);
}
/**
* Gets the arrow thickness.
*
* @type {number}
*/ get axisArrowThickness() {
return this._shapes.x.arrowThickness;
}
/**
* Sets the arrow length.
*
* @type {number}
*/ set axisArrowLength(value) {
this._setArrowProp('arrowLength', value);
}
/**
* Gets the arrow length.
*
* @type {number}
*/ get axisArrowLength() {
return this._shapes.x.arrowLength;
}
/**
* Sets the plane size.
*
* @type {number}
*/ set axisPlaneSize(value) {
this._setPlaneProp('size', value);
}
/**
* Gets the plane size.
*
* @type {number}
*/ get axisPlaneSize() {
return this._shapes.yz.size;
}
/**
* Sets the plane gap.
*
* @type {number}
*/ set axisPlaneGap(value) {
this._setPlaneProp('gap', value);
}
/**
* Gets the plane gap.
*
* @type {number}
*/ get axisPlaneGap() {
return this._shapes.yz.gap;
}
/**
* Sets the axis center size.
*
* @type {number}
*/ set axisCenterSize(value) {
this._shapes.xyz.radius = value;
}
/**
* Gets the axis center size.
*
* @type {number}
*/ get axisCenterSize() {
return this._shapes.xyz.radius;
}
/**
* @type {boolean}
* @deprecated Use {@link TranslateGizmo#flipPlanes} instead.
* @ignore
*/ set flipShapes(value) {
this.flipPlanes = value;
}
/**
* @type {boolean}
* @deprecated Use {@link TranslateGizmo#flipPlanes} instead.
* @ignore
*/ get flipShapes() {
return this.flipPlanes;
}
/**
* @param {string} prop - The property to set.
* @param {any} value - The value to set.
* @private
*/ _setArrowProp(prop, value) {
this._shapes.x[prop] = value;
this._shapes.y[prop] = value;
this._shapes.z[prop] = value;
}
/**
* @param {string} prop - The property to set.
* @param {any} value - The value to set.
* @private
*/ _setPlaneProp(prop, value) {
this._shapes.yz[prop] = value;
this._shapes.xz[prop] = value;
this._shapes.xy[prop] = value;
}
/**
* @private
*/ _shapesLookAtCamera() {
const cameraDir = this.cameraDir;
// axes
let changed = false;
let dot, enabled;
dot = cameraDir.dot(this.root.right);
enabled = 1 - Math.abs(dot) > GLANCE_EPSILON;
if (this._shapes.x.entity.enabled !== enabled) {
this._shapes.x.entity.enabled = enabled;
changed = true;
}
dot = cameraDir.dot(this.root.up);
enabled = 1 - Math.abs(dot) > GLANCE_EPSILON;
if (this._shapes.y.entity.enabled !== enabled) {
this._shapes.y.entity.enabled = enabled;
changed = true;
}
dot = cameraDir.dot(this.root.forward);
enabled = 1 - Math.abs(dot) > GLANCE_EPSILON;
if (this._shapes.z.entity.enabled !== enabled) {
this._shapes.z.entity.enabled = enabled;
changed = true;
}
// planes
let flipped;
v1.cross(cameraDir, this.root.right);
enabled = 1 - v1.length() > GLANCE_EPSILON;
if (this._shapes.yz.entity.enabled !== enabled) {
this._shapes.yz.entity.enabled = enabled;
changed = true;
}
flipped = this.flipPlanes ? v2.set(0, +(v1.dot(this.root.forward) < 0), +(v1.dot(this.root.up) < 0)) : Vec3.ZERO;
if (!this._shapes.yz.flipped.equals(flipped)) {
this._shapes.yz.flipped = flipped;
changed = true;
}
v1.cross(cameraDir, this.root.forward);
enabled = 1 - v1.length() > GLANCE_EPSILON;
if (this._shapes.xy.entity.enabled !== enabled) {
this._shapes.xy.entity.enabled = enabled;
changed = true;
}
flipped = this.flipPlanes ? v2.set(+(v1.dot(this.root.up) < 0), +(v1.dot(this.root.right) > 0), 0) : Vec3.ZERO;
if (!this._shapes.xy.flipped.equals(flipped)) {
this._shapes.xy.flipped = flipped;
changed = true;
}
v1.cross(cameraDir, this.root.up);
enabled = 1 - v1.length() > GLANCE_EPSILON;
if (this._shapes.xz.entity.enabled !== enabled) {
this._shapes.xz.entity.enabled = enabled;
changed = true;
}
flipped = this.flipPlanes ? v2.set(+(v1.dot(this.root.forward) > 0), 0, +(v1.dot(this.root.right) > 0)) : Vec3.ZERO;
if (!this._shapes.xz.flipped.equals(flipped)) {
this._shapes.xz.flipped = flipped;
changed = true;
}
if (changed) {
this._renderUpdate = true;
}
}
/**
* @param {boolean} state - The state.
* @private
*/ _drag(state) {
for(const axis in this._shapes){
const shape = this._shapes[axis];
switch(this.dragMode){
case 'show':
{
continue;
}
case 'hide':
{
shape.visible = !state;
continue;
}
case 'selected':
{
if (this._selectedAxis === 'xyz') {
shape.visible = state ? axis.length === 1 : true;
continue;
}
if (this._selectedIsPlane) {
shape.visible = state ? axis.length === 1 && !axis.includes(this._selectedAxis) : true;
continue;
}
shape.visible = state ? axis === this._selectedAxis : true;
}
}
}
this._renderUpdate = true;
}
/**
* @private
*/ _storeNodePositions() {
for(let i = 0; i < this.nodes.length; i++){
const node = this.nodes[i];
this._nodeLocalPositions.set(node, node.getLocalPosition().clone());
this._nodePositions.set(node, node.getPosition().clone());
}
}
/**
* @param {Vec3} translateDelta - The delta to apply to the node positions.
* @private
*/ _setNodePositions(translateDelta) {
for(let i = 0; i < this.nodes.length; i++){
const node = this.nodes[i];
if (this._coordSpace === 'local') {
const pos = this._nodeLocalPositions.get(node);
if (!pos) {
continue;
}
v1.copy(translateDelta);
node.parent?.getWorldTransform().getScale(v2);
v2.x = 1 / v2.x;
v2.y = 1 / v2.y;
v2.z = 1 / v2.z;
q.copy(node.getLocalRotation()).transformVector(v1, v1);
v1.mul(v2);
node.setLocalPosition(v1.add(pos));
} else {
const pos = this._nodePositions.get(node);
if (!pos) {
continue;
}
node.setPosition(v1.copy(translateDelta).add(pos));
}
}
this._updatePosition();
}
/**
* @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 isPlane = this._selectedIsPlane;
const ray = this._createRay(mouseWPos);
const plane = this._createPlane(axis, axis === 'xyz', !isPlane);
if (!plane.intersectsRay(ray, point)) {
return point;
}
// rotate point back to world coords
q.copy(this._rootStartRot).invert().transformVector(point, point);
// project point onto axis
if (!isPlane && axis !== 'xyz') {
this._projectToAxis(point, axis);
}
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.
* @override
*/ _drawGuideLines(pos, rot, activeAxis, activeIsPlane) {
for (const axis of AXES){
if (this._dragging || 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);
}
}
}
}
/**
* @override
*/ prerender() {
super.prerender();
if (!this.enabled) {
return;
}
this._shapesLookAtCamera();
}
}
export { TranslateGizmo };