mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user