playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
448 lines (447 loc) • 13.6 kB
JavaScript
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";
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();
const RING_FACING_EPSILON = 1e-4;
const UPDATE_EPSILON = 1e-6;
const AXES = ["x", "y", "z"];
class RotateGizmo extends TransformGizmo {
_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
})
};
_selectionStartAngle = 0;
_nodeLocalRotations = /* @__PURE__ */ new Map();
_nodeRotations = /* @__PURE__ */ new Map();
_nodeOffsets = /* @__PURE__ */ new Map();
_screenPos = new Vec2();
_screenStartPos = new Vec2();
_guideAngleStart = new Vec3();
_guideAngleEnd = new Vec3();
_guideAngleLines;
_facingDir = new Vec3();
snapIncrement = 5;
rotationMode = "absolute";
constructor(camera, layer) {
super(camera, layer, "gizmo:rotate");
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, (point2, x, y) => {
this._screenPos.set(x, y);
this._screenStartPos.set(x, y);
this._selectionStartAngle = this._calculateArcAngle(point2, x, y);
this._storeNodeRotations();
this._storeGuidePoints();
this._drag(true);
this._angleGuide(true);
});
this.on(TransformGizmo.EVENT_TRANSFORMMOVE, (point2, x, y) => {
const axis = this._selectedAxis;
if (!axis) {
return;
}
this._screenPos.set(x, y);
if (axis === "xyz") {
const facingDir = v1.copy(this.facingDir);
const delta = v2.copy(point2).sub(this._selectionStartPoint);
const angleAxis = v1.cross(facingDir, delta).normalize();
const angleDelta = this._screenPos.distance(this._screenStartPos);
this._setNodeRotations(axis, angleAxis, angleDelta);
} else {
let angleDelta = this._calculateArcAngle(point2, 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);
this._updateGuidePoints(angleDelta);
this._angleGuide(true);
}
});
this.on(TransformGizmo.EVENT_TRANSFORMEND, () => {
this._drag(false);
this._angleGuide(false);
});
this.on(TransformGizmo.EVENT_NODESDETACH, () => {
this._nodeLocalRotations.clear();
this._nodeRotations.clear();
this._nodeOffsets.clear();
});
}
set xyzTubeRadius(value) {
this._setDiskProp("tubeRadius", value);
}
get xyzTubeRadius() {
return this._shapes.x.tubeRadius;
}
set xyzRingRadius(value) {
this._setDiskProp("ringRadius", value);
}
get xyzRingRadius() {
return this._shapes.x.ringRadius;
}
set faceTubeRadius(value) {
this._shapes.f.tubeRadius = value;
}
get faceTubeRadius() {
return this._shapes.f.tubeRadius;
}
set faceRingRadius(value) {
this._shapes.f.ringRadius = value;
}
get faceRingRadius() {
return this._shapes.f.ringRadius;
}
set centerRadius(value) {
this._shapes.xyz.radius = value;
}
get centerRadius() {
return this._shapes.xyz.radius;
}
set ringTolerance(value) {
this._setDiskProp("tolerance", value);
this._shapes.f.tolerance = value;
}
get ringTolerance() {
return this._shapes.x.tolerance;
}
set angleGuideThickness(value) {
this._guideAngleLines[0].thickness = value;
this._guideAngleLines[1].thickness = value;
}
get angleGuideThickness() {
return this._guideAngleLines[0].thickness;
}
set orbitRotation(value) {
this.rotationMode = value ? "orbit" : "absolute";
}
get orbitRotation() {
return this.rotationMode === "orbit";
}
_setDiskProp(prop, value) {
this._shapes.x[prop] = value;
this._shapes.y[prop] = value;
this._shapes.z[prop] = value;
}
_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);
}
_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;
}
_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;
}
}
_shapesLookAtCamera() {
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);
}
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;
}
}
_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;
}
_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));
}
}
_setNodeRotations(axis, angleAxis, angleDelta) {
const gizmoPos = this.root.getLocalPosition();
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();
}
}
_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)) {
ray.direction.mulScalar(-1);
const intersection = plane.intersectsRay(ray, point);
ray.direction.mulScalar(-1);
if (!intersection) {
return point.copy(this.root.getLocalPosition());
}
}
return point;
}
_calculateArcAngle(point2, x, y) {
const gizmoPos = this.root.getLocalPosition();
const axis = this._selectedAxis;
const plane = this._createPlane(axis, axis === "f", false);
let angle = 0;
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) {
v1.set(
this._screenStartPos.y >= v2.y ? 1 : -1,
this._screenStartPos.x >= v2.x ? -1 : 1,
0
).normalize();
} else {
const projDir = v1.cross(plane.normal, facingDir).normalize();
this._camera.worldToScreen(projDir.add(gizmoPos), v3);
v1.sub2(v3, v2).normalize();
}
v2.set(x, y, 0);
angle = v1.dot(v2);
break;
}
case "orbit": {
v1.sub2(point2, gizmoPos);
switch (axis) {
case "x": {
q1.copy(this._rootStartRot).invert().transformVector(v1, v1);
angle = Math.atan2(v1.z, v1.y) * math.RAD_TO_DEG;
break;
}
case "y": {
q1.copy(this._rootStartRot).invert().transformVector(v1, v1);
angle = Math.atan2(v1.x, v1.z) * math.RAD_TO_DEG;
break;
}
case "z": {
q1.copy(this._rootStartRot).invert().transformVector(v1, v1);
angle = Math.atan2(v1.y, v1.x) * math.RAD_TO_DEG;
break;
}
case "f": {
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;
}
}
const dir = v1.sub2(point2, this._camera.entity.getPosition()).normalize();
const dot = dir.dot(this._camera.entity.forward);
if (dot < 0) {
angle += 180;
}
break;
}
}
return angle;
}
_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);
}
}
}
}
prerender() {
super.prerender();
if (!this.enabled) {
return;
}
this._shapesLookAtCamera();
}
destroy() {
this._guideAngleLines.forEach((line) => line.destroy());
super.destroy();
}
}
export {
RotateGizmo
};