Fix rotation inspector (#104)

This PR updates the Transform > Rotation section of the Entity Inspector
to display Euler angles in degrees instead of the vector component of
the input quaternion.

Additionally, this updates the `studio/ui/inspector` module to add a
special-case function for updating the selected entity's rotation from
the inspector in a way that's consistent with the behavior of the
rotation gizmo.

### Known Issues

The values displayed for the angles in the inspector are not always
intuitive, especially while actively manipulating them. To some extent
this is just the nature of Euler angles (e.g., Unreal Engine exhibits
similar issues), but I suspect there's room for improvement of our
implementation (e.g., Unreal's rotation display is typically _more_ sane
than ours).

Regardless, fixing this requires a firmer grasp of the math than I
possess, so a fix for this issue is not currently planned.
This commit is contained in:
Danny McGee
2024-03-26 14:07:09 -04:00
committed by GitHub
parent f28b7602fb
commit daabc4d562
6 changed files with 415 additions and 42 deletions
@@ -0,0 +1,31 @@
@use "sass:math";
:host {
display: flex;
gap: 0.25rem;
}
.input {
padding: 0.5rem;
width: math.div(1, 3) * 100%;
border-left-width: 4px;
&:nth-of-type(1) {
border-left-color: rgb(220, 53, 69);
&:focus {
border-left-color: rgb(255, 48, 68);
}
}
&:nth-of-type(2) {
border-left-color: rgb(25, 135, 84);
&:focus {
border-left-color: rgb(0, 187, 100);
}
}
&:nth-of-type(3) {
border-left-color: rgb(13, 110, 253);
&:focus {
border-left-color: rgb(47, 131, 255);
}
}
}
@@ -0,0 +1,126 @@
import * as studio from "@storyteller/studio";
import { RotationAxis } from "@storyteller/studio";
import { match } from "@storyteller/utility";
import { LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type Quat, type Vec3 } from "./inspector.types";
import styles from "./quat-inspector.element.scss?inline";
@customElement("sts-quat-inspector")
export class QuatInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
value: Quat = { w: 1, x: 0, y: 0, z: 0 };
@state() _eulerX = 0;
@state() _eulerY = 0;
@state() _eulerZ = 0;
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("value")) {
const { x, y, z } = quatToEuler(this.value);
this._eulerX = rad2deg(x);
this._eulerY = rad2deg(y);
this._eulerZ = rad2deg(z);
}
super.willUpdate(changes);
}
#emitUpdate(axis: RotationAxis) {
return ({ detail: newAngle }: CustomEvent<number>) => {
const prevAngle = match (axis, {
[RotationAxis.X]: () => this._eulerX,
[RotationAxis.Y]: () => this._eulerY,
[RotationAxis.Z]: () => this._eulerZ,
});
const deltaTheta = deg2rad(newAngle - prevAngle);
if (Number.isFinite(deltaTheta) && !Number.isNaN(deltaTheta))
studio.rotateLocalTransform(axis, deltaTheta);
}
}
protected override render = (): TemplateResult => html`
<sts-scalar-field
class="input"
aria-label="X"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerX}
@value-change=${this.#emitUpdate(RotationAxis.X)}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerY}
@value-change=${this.#emitUpdate(RotationAxis.Y)}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerZ}
@value-change=${this.#emitUpdate(RotationAxis.Z)}
></sts-scalar-field>
`;
}
// Reference: https://gamemath.com/book/orient.html#quaternion_to_euler_angles
// TODO: The fields here "work" on a basic level, but I'm not convinced they're
// actually displaying intelligible values. For example, if you tilt the
// X-axis by any amount and rotate the Y-axis, you'll typically see all
// three values spinning wildly. I hate Euler angles.
function quatToEuler({ w, x, y, z }: Quat): Vec3 {
let sinPitch = -2 * (y*z - w*x);
// Clamp in the range of [-1,1] to avoid NaNs
sinPitch = Math.min(Math.max(sinPitch, -1), 1);
// Check for gimbal lock
if (nearlyEq(sinPitch, 1, 1e-3)) {
const yaw = Math.atan2(-x*z + w*y, 0.5 - y*y - z*z);
const pitch = Math.PI * 0.5 * sinPitch;
const roll = 0;
return {
x: pitch,
y: yaw,
z: roll,
}
}
const yaw = Math.atan2(x*z + w*y, 0.5 - x*x - y*y);
const pitch = Math.asin(sinPitch);
const roll = Math.atan2(x*y + w*z, 0.5 - x*x - z*z);
return {
x: pitch,
y: yaw,
z: roll,
}
}
function nearlyEq(lhs: number, rhs: number, tolerance = Number.EPSILON): boolean {
return Math.abs(lhs - rhs) < tolerance;
}
function rad2deg(rad: number): number {
return rad * (180 / Math.PI);
}
function deg2rad(deg: number): number {
return (deg * Math.PI) / 180;
}
@@ -61,3 +61,7 @@ h4 {
}
}
}
sts-quat-inspector {
width: 100%;
}
@@ -6,6 +6,7 @@ import { v4 as uuid } from "uuid";
import { type Quat, type Vec3 } from "./inspector.types";
import "@storyteller/studio-ui/forms/scalar-field";
import "./quat-inspector.element";
import styles from "./transform-inspector.element.scss?inline";
@@ -26,16 +27,14 @@ export class TransformInspectorElement extends LitElement {
#rotationLabelId = uuid();
#scaleLabelId = uuid();
updateField(reflectPath: `${"translation"|"rotation"|"scale"}.${"x"|"y"|"z"}`) {
return ({ detail }: CustomEvent<string>) => {
const value = parseFloat(detail);
if (Number.isFinite(value) && !Number.isNaN(value)) {
studio.updateComponentField({
component: "bevy_transform::components::transform::Transform",
reflectPath,
value,
});
}
updateField(reflectPath: `${"translation"|"scale"}.${"x"|"y"|"z"}` | "rotation") {
return ({ detail: value }: CustomEvent<number | Quat>) => {
console.log("value:", value);
studio.updateComponentField({
component: "bevy_transform::components::transform::Transform",
reflectPath,
value,
});
}
}
@@ -52,18 +51,24 @@ export class TransformInspectorElement extends LitElement {
<sts-scalar-field
class="input"
aria-label="X"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.x}
@value-change=${this.updateField("translation.x")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.y}
@value-change=${this.updateField("translation.y")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.z}
@value-change=${this.updateField("translation.z")}
></sts-scalar-field>
@@ -76,26 +81,10 @@ export class TransformInspectorElement extends LitElement {
<legend id=${this.#rotationLabelId}>
Rotation
</legend>
<!-- TODO: This is not how quaternions work lol -->
<sts-scalar-field
class="input"
aria-label="X"
.value=${this.rotation.x}
@value-change=${this.updateField("rotation.x")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
.value=${this.rotation.y}
@value-change=${this.updateField("rotation.y")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
.value=${this.rotation.z}
@value-change=${this.updateField("rotation.z")}
></sts-scalar-field>
<!-- <input type="number" value=${this.rotation.w.toFixed(3)} /> -->
<sts-quat-inspector
.value=${this.rotation}
@value-change=${this.updateField("rotation")}
></sts-quat-inspector>
</div>
<div
class="fieldset"
@@ -108,18 +97,24 @@ export class TransformInspectorElement extends LitElement {
<sts-scalar-field
class="input"
aria-label="X"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.x}
@value-change=${this.updateField("scale.x")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.y}
@value-change=${this.updateField("scale.y")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.z}
@value-change=${this.updateField("scale.z")}
></sts-scalar-field>