mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Animation Editor UI (#20)
This PR adds a WIP timeline UI for animating elements in the scene. ### Implemented Features * The timeline can be panned with MMB and zoomed with Ctrl + Mouse Wheel * The timeline can be "scrubbed" by dragging the red "play-head" with LMB * The timeline sidebar (displaying the list of tracks) can be resized by dragging on the edge of the sidebar * Keyframes can be added by pressing the "Add Keyframe" CTA in the UI * Keyframes can be selected for editing by clicking the diamond-shaped markers in the timeline * Selected entities (including but not limited to keyframes) can be deleted by pressing the "Delete" key * Keyframes can be moved temporally by dragging the diamond-shaped markers in the timeline ### Known issues * Zooming the timeline in too far results in a soft-lock * When zooming the timeline, the "transform origin" is currently centered on the `00:00` timestamp. For ease of use, it should center on the mouse cursor position instead. * The timeline track-list is currently hard-coded to list the Camera and Camera Target. Clicking on these entries currently serves no useful purpose and may result in a crash. * Keyframe markers in the timeline are not currently associated with entries in the track-list and simply appear vertically centered in the timeline view. * Added keyframes currently record the position and orientation of the scene camera when added, and are rendered in the world with debug visualizations, but serve no useful purpose. Some design work is needed (both user-experience and architectural) to decide on a workflow for actually animating scene elements. * Entity deletion cannot currently be undone/redone
This commit is contained in:
Generated
+11
-1
@@ -1483,7 +1483,7 @@ dependencies = [
|
||||
"fxhash",
|
||||
"glam",
|
||||
"indexmap 2.0.0",
|
||||
"itertools",
|
||||
"itertools 0.11.0",
|
||||
"nalgebra",
|
||||
"parry3d",
|
||||
]
|
||||
@@ -2991,6 +2991,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.9"
|
||||
@@ -4541,6 +4550,7 @@ dependencies = [
|
||||
"futures-intrusive",
|
||||
"futures-lite 2.2.0",
|
||||
"image",
|
||||
"itertools 0.12.1",
|
||||
"js-sys",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./lib/decorators";
|
||||
export * from "./lib/di";
|
||||
export { default as spread } from "./lib/directives/spread";
|
||||
export { default as drag, type Props as DragProps } from "./lib/directives/drag";
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ProviderRemoval<T> extends CustomEvent<Provider<T>> {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
[DIEvent.InjectionRequest]: InjectionRequest<any>;
|
||||
[DIEvent.DependencyProvision]: DependencyProvision<any>;
|
||||
[DIEvent.ProviderRemoval]: ProviderRemoval<any>;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ElementPart, noChange, nothing } from "lit";
|
||||
import { directive, Directive } from "lit/directive.js";
|
||||
|
||||
export interface Props {
|
||||
button?: number;
|
||||
start?(event: PointerEvent): void;
|
||||
move?(event: PointerEvent): void;
|
||||
end?(event: PointerEvent): void;
|
||||
}
|
||||
|
||||
class Drag extends Directive {
|
||||
#initialized = false;
|
||||
#pointerButton = 0;
|
||||
|
||||
#startHandler?: (event: PointerEvent) => void;
|
||||
#moveHandler?: (event: PointerEvent) => void;
|
||||
|
||||
#lastProps: Props = {};
|
||||
|
||||
override render(_: Props) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
override update(part: ElementPart, [props]: [Props]) {
|
||||
const element = part.element as HTMLElement;
|
||||
const host = part.options?.host;
|
||||
|
||||
// Disable the default drag-event handlers, which can interfere with this directive
|
||||
if (!this.#initialized) {
|
||||
element.addEventListener("dragstart", event => event.preventDefault());
|
||||
element.addEventListener("drag", event => event.preventDefault());
|
||||
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
// Return early if there are no changes in the passed props
|
||||
if (
|
||||
this.#lastProps.button === props.button
|
||||
&& this.#lastProps.start === props.start
|
||||
&& this.#lastProps.move === props.move
|
||||
&& this.#lastProps.end === props.end
|
||||
) {
|
||||
return noChange;
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// This makes it impossible to replace the move handler on an element with
|
||||
// a different one on subsequent renders, but this may be the desired
|
||||
// behavior? This allows the move handler to be bound as a lambda that
|
||||
// will keep firing even after the DOM re-renders. See
|
||||
// studio-frontend/src/app/anim-timeline.element.ts for the use case.
|
||||
if (this.#moveHandler != null)
|
||||
return noChange;
|
||||
|
||||
this.#lastProps = { ...props };
|
||||
|
||||
// Clean up previous event handlers
|
||||
if (this.#startHandler)
|
||||
element.removeEventListener("pointerdown", this.#startHandler);
|
||||
|
||||
if (this.#moveHandler)
|
||||
// TODO: This statement is unreachable due to the early-return above
|
||||
window.removeEventListener("pointermove", this.#moveHandler);
|
||||
|
||||
// Set the pointer button which should activate dragging
|
||||
this.#pointerButton = (props.button != null) ? props.button : 0;
|
||||
|
||||
// Main event-handling behavior
|
||||
//
|
||||
// It's important to not bind any `window` events unconditionally here,
|
||||
// because those handlers could easily outlive the element this directive
|
||||
// is attached to (the "host" element), which would cause a memory leak.
|
||||
//
|
||||
// Instead, we add a single `pointerdown` listener to the host element,
|
||||
// and inside that listener, we add the `pointermove` listener to the
|
||||
// window. By removing the `pointermove` listener on `pointerup`, we
|
||||
// shouldn't have any long-lived event listeners holding (potentially
|
||||
// stale) references to the host element.
|
||||
|
||||
this.#moveHandler = (event: PointerEvent) => {
|
||||
props.move?.call(host ?? event, event);
|
||||
}
|
||||
|
||||
this.#startHandler = (event: PointerEvent) => {
|
||||
if (event.button !== this.#pointerButton)
|
||||
return;
|
||||
|
||||
window.addEventListener("pointermove", this.#moveHandler!);
|
||||
|
||||
window.addEventListener("pointerup", event => {
|
||||
props.end?.call(host ?? event, event);
|
||||
window.removeEventListener("pointermove", this.#moveHandler!);
|
||||
}, {
|
||||
once: true,
|
||||
});
|
||||
|
||||
props.start?.call(host ?? event, event);
|
||||
}
|
||||
|
||||
element.addEventListener("pointerdown", this.#startHandler);
|
||||
|
||||
return noChange;
|
||||
}
|
||||
}
|
||||
|
||||
export default directive(Drag);
|
||||
@@ -0,0 +1,88 @@
|
||||
:host {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.sidebar, .timeline { padding: 8px; }
|
||||
.sidebar { padding-inline-end: 0; }
|
||||
.timeline { padding-inline-start: 0; }
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-keyframe {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
background: #7A2C34E3;
|
||||
color: #FFF;
|
||||
|
||||
&:hover {
|
||||
background: #90363F;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-nav {
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
sts-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent !important;
|
||||
backdrop-filter: initial;
|
||||
color: #FFFC;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #FFFF;
|
||||
}
|
||||
|
||||
&.play {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
flex: 0 0 8px;
|
||||
margin: {
|
||||
right: -3px;
|
||||
left: -3px;
|
||||
}
|
||||
cursor: ew-resize;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3px;
|
||||
bottom: 0;
|
||||
left: 3px;
|
||||
background: #FFF2;
|
||||
opacity: 0;
|
||||
transition: opacity 66.666ms linear;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { type CameraKeyframesEvent } from "@storyteller/studio";
|
||||
import { drag, on } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import "./anim-timeline.element";
|
||||
import "./button.element";
|
||||
import "./toolbar.element";
|
||||
import "./tree.element";
|
||||
|
||||
import styles from "./anim-editor.element.scss?inline";
|
||||
|
||||
@customElement("sts-anim-editor")
|
||||
export class AnimEditorElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@state() sidebarWidth = 256;
|
||||
|
||||
@state() timelineStart = 0;
|
||||
@state() timelineEnd = 5_000;
|
||||
|
||||
@state() clipStart = 0;
|
||||
@state() clipEnd = 15_000;
|
||||
|
||||
@state() playHead = 0;
|
||||
@state() keyframes: Record<string, number> = {};
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
if (changes.has("playHead"))
|
||||
studio.seekTime(this.playHead);
|
||||
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
@on("window:camera-keyframes")
|
||||
onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void {
|
||||
this.keyframes = keyframes;
|
||||
}
|
||||
|
||||
onSidebarResize(event: PointerEvent): void {
|
||||
this.sidebarWidth = Math.max(Math.min(this.sidebarWidth + event.movementX, 512), 128);
|
||||
}
|
||||
|
||||
addKeyframe(): void {
|
||||
studio.addCameraKeyframe(this.playHead);
|
||||
}
|
||||
|
||||
jumpToStart(): void {
|
||||
this.playHead = this.clipStart;
|
||||
}
|
||||
|
||||
prevKeyframe(): void {
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => b - a);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe < this.playHead)
|
||||
break;
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
}
|
||||
|
||||
nextKeyframe(): void {
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => a - b);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe > this.playHead)
|
||||
break;
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
}
|
||||
|
||||
jumpToEnd(): void {
|
||||
this.playHead = this.clipEnd;
|
||||
}
|
||||
|
||||
#onTimelineStartChange(event: CustomEvent<number>): void {
|
||||
this.timelineStart = event.detail;
|
||||
}
|
||||
|
||||
#onTimelineEndChange(event: CustomEvent<number>): void {
|
||||
this.timelineEnd = event.detail;
|
||||
}
|
||||
|
||||
#onPlayHeadChange(event: CustomEvent<number>): void {
|
||||
this.playHead = event.detail;
|
||||
}
|
||||
|
||||
#onKeyframesChange(event: CustomEvent<Record<string, number>>): void {
|
||||
this.keyframes = event.detail;
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<div class="sidebar"
|
||||
style=${styleMap({
|
||||
width: `${this.sidebarWidth}px`,
|
||||
})}
|
||||
>
|
||||
<sts-toolbar>
|
||||
<sts-toolbar-group>
|
||||
<sts-button class="btn-keyframe"
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
<sts-toolbar-group class="timeline-nav">
|
||||
<sts-button
|
||||
icon="bwd-fast"
|
||||
aria-label="Jump to Start"
|
||||
title="Jump to Start"
|
||||
@click=${this.jumpToStart}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="bwd-step"
|
||||
aria-label="Previous Keyframe"
|
||||
title="Previous Keyframe"
|
||||
@click=${this.prevKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="play"
|
||||
icon="play"
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-step"
|
||||
aria-label="Next Keyframe"
|
||||
title="Next Keyframe"
|
||||
@click=${this.nextKeyframe}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-fast"
|
||||
aria-label="Jump to End"
|
||||
title="Jump to End"
|
||||
@click=${this.jumpToEnd}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
</sts-toolbar>
|
||||
<div class="tracklist" role="list">
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera"
|
||||
icon="video"
|
||||
></sts-tree>
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera Target"
|
||||
icon="crosshairs"
|
||||
></sts-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"
|
||||
${drag({
|
||||
move: this.onSidebarResize,
|
||||
})}
|
||||
></div>
|
||||
|
||||
<sts-anim-timeline class="timeline"
|
||||
.rangeStart=${this.timelineStart}
|
||||
@rangeStartChange=${this.#onTimelineStartChange}
|
||||
.rangeEnd=${this.timelineEnd}
|
||||
@rangeEndChange=${this.#onTimelineEndChange}
|
||||
|
||||
.clipStart=${this.clipStart}
|
||||
.clipEnd=${this.clipEnd}
|
||||
|
||||
.playHead=${this.playHead}
|
||||
@playHeadChange=${this.#onPlayHeadChange}
|
||||
|
||||
.keyframes=${this.keyframes}
|
||||
@keyframesChange=${this.#onKeyframesChange}
|
||||
></sts-anim-timeline>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
:host {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-region {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #0004;
|
||||
}
|
||||
|
||||
.hash {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #FFF1;
|
||||
|
||||
&.strong {
|
||||
background: #FFF3;
|
||||
}
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
color: #FFF6;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: 14px;
|
||||
}
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
.frac {
|
||||
color: #FFF3;
|
||||
}
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
transform: translateX(-3px);
|
||||
cursor: ew-resize;
|
||||
color: #7A2C34E3;
|
||||
transition: color 66.667ms linear;
|
||||
|
||||
&:hover {
|
||||
color: #90363F;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 32px;
|
||||
background: currentColor;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 12px;
|
||||
border: 4px solid transparent {
|
||||
top: 8px solid currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
bottom: 0;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.keyframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateX(-4.5px) translateY(-4.5px);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #AAA;
|
||||
transform: rotate(45deg) {
|
||||
origin: center center;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected::before {
|
||||
background: #0FF; // TODO
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { type EntitySelectEvent } from "@storyteller/studio";
|
||||
import { drag, on } from "@storyteller/framework";
|
||||
import { LitElement, PropertyValues, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import styles from "./anim-timeline.element.scss?inline";
|
||||
|
||||
// TODO: This is basically a [signal](https://www.solidjs.com/tutorial/introduction_signals)
|
||||
// that requires manual dependency tracking. Given that requirement, it's
|
||||
// questionable whether this is even a valuable abstraction to have.
|
||||
//
|
||||
// We should either make it more obviously valuable (e.g. by coming up
|
||||
// with a way to automatically handle dependencies) and move it to
|
||||
// `@storyteller/framework`, or just get rid of it.
|
||||
//
|
||||
// We could maybe add a decorator that could automatically override the
|
||||
// `update` method to invalidate the cache when dependencies are changed,
|
||||
// which would look something like:
|
||||
// ```
|
||||
// @memo(["rangeStart", "rangeEnd", "_renderWidth"])
|
||||
// msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
// ```
|
||||
// But that feels a little obtuse and overengineered. In this particular
|
||||
// case we're probably only saving on the order of nanoseconds, so I'm
|
||||
// leaning toward just nuking the idea.
|
||||
class Computed extends Number {
|
||||
#value?: number;
|
||||
#compute: () => number;
|
||||
|
||||
constructor (compute: () => number) {
|
||||
const value = compute();
|
||||
super(value);
|
||||
this.#compute = compute;
|
||||
}
|
||||
|
||||
override valueOf(): number {
|
||||
return this.#value ??= this.#compute();
|
||||
}
|
||||
|
||||
get(): number {
|
||||
return this.valueOf();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.#value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("sts-anim-timeline")
|
||||
export class AnimTimelineElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
/** The time (in ms) at the left edge of the timeline view */
|
||||
@property({ type: Number }) rangeStart = 0;
|
||||
/** The time (in ms) at the right edge of the timeline view */
|
||||
@property({ type: Number }) rangeEnd = 5_000;
|
||||
|
||||
/** The time (in ms) at the beginning of the active clip range */
|
||||
@property({ type: Number }) clipStart = 0;
|
||||
/** The time (in ms) at the end of the active clip range */
|
||||
@property({ type: Number }) clipEnd = 15_000;
|
||||
|
||||
/** The time (in ms) of the current play-head position */
|
||||
@property({ type: Number }) playHead = 0;
|
||||
/** Keyframe positions (in ms) */
|
||||
@property({ attribute: false }) keyframes: Record<string, number> = {};
|
||||
|
||||
@state() _selectedKeyframe: string | null = null;
|
||||
|
||||
/** The width (in px) of the timeline view */
|
||||
@state() _renderWidth!: number;
|
||||
|
||||
/** The offset from 0ms of the left edge of the timeline view, in px */
|
||||
@state() _offsetX = 0;
|
||||
/** The vertical scroll offset (in px) */
|
||||
@state() _offsetY = 0;
|
||||
|
||||
/**
|
||||
* `true` when the timeline is being mouse-panned. Primarily useful for
|
||||
* setting the cursor style to the grabby hand.
|
||||
*/
|
||||
@state() _panning = false;
|
||||
|
||||
/** How many ms are represented by each px of horizontal space. */
|
||||
msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
|
||||
#resizeObserver: ResizeObserver;
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#updateRenderWidth());
|
||||
this.#resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#updateRenderWidth();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
if (
|
||||
changes.has("rangeStart")
|
||||
|| changes.has("rangeEnd")
|
||||
|| changes.has("_renderWidth")
|
||||
) {
|
||||
this.msPerPx.invalidate();
|
||||
}
|
||||
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
@on("wheel")
|
||||
onWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.ctrlKey) {
|
||||
this.#zoomTimeline(event.offsetX, event.deltaY);
|
||||
} else if (event.deltaX) {
|
||||
this.#pan(-event.deltaX);
|
||||
} else if (event.shiftKey && event.deltaY) {
|
||||
this.#pan(-event.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
@on("window:entity-select")
|
||||
onEntitySelected({ detail: id }: EntitySelectEvent): void {
|
||||
if (!id) {
|
||||
this._selectedKeyframe = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.from(Object.keys(this.keyframes)).includes(id)) {
|
||||
this._selectedKeyframe = id;
|
||||
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: this.keyframes[id]! }));
|
||||
} else {
|
||||
this._selectedKeyframe = null;
|
||||
}
|
||||
}
|
||||
|
||||
selectKeyframe(id: string): void {
|
||||
studio.select(id);
|
||||
}
|
||||
|
||||
#zoomTimeline(origin: number, delta: number): void {
|
||||
const currentRange = this.rangeEnd - this.rangeStart;
|
||||
const deltaRange = delta * 4;
|
||||
|
||||
// FIXME: When zooming the timeline, the transform origin should be
|
||||
// centered on the mouse cursor. Currently this does not work as
|
||||
// intended, always centering on the 00:00 timestamp instead.
|
||||
const t = ((this._offsetX + origin) * this.msPerPx.get()) / currentRange;
|
||||
console.log(`t: ${t}`);
|
||||
console.log(`deltaStart: ${-deltaRange * t}`);
|
||||
console.log(`deltaEnd: ${deltaRange * (1 - t)}`);
|
||||
const rangeStart = this.rangeStart - deltaRange * t;
|
||||
const rangeEnd = this.rangeEnd + deltaRange * (1 - t);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onDragStart(): void {
|
||||
this._panning = true;
|
||||
}
|
||||
|
||||
#onDragEnd(): void {
|
||||
this._panning = false;
|
||||
}
|
||||
|
||||
#onDrag(event: PointerEvent): void {
|
||||
this.#pan(event.movementX);
|
||||
}
|
||||
|
||||
#pan(deltaPx: number): void {
|
||||
this._offsetX += deltaPx;
|
||||
|
||||
const msPerPx = this.msPerPx.get();
|
||||
const rangeStart = this.rangeStart - deltaPx * msPerPx;
|
||||
const rangeEnd = this.rangeEnd - deltaPx * msPerPx;
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onPlayheadDrag(event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.playHead + deltaMs;
|
||||
|
||||
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: updated }));
|
||||
}
|
||||
|
||||
#onKeyframeDrag(id: string, event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.keyframes[id] + deltaMs;
|
||||
|
||||
studio.updateKeyframe(id, updated);
|
||||
|
||||
const updatedKeyframes = Object.fromEntries(
|
||||
Object
|
||||
.entries(this.keyframes)
|
||||
.map(entry => entry[0] === id
|
||||
? [id, updated]
|
||||
: entry
|
||||
)
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("keyframesChange", { detail: updatedKeyframes }));
|
||||
}
|
||||
|
||||
#updateRenderWidth(): void {
|
||||
this._renderWidth = this.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time value (in ms) for displaying in the timeline view
|
||||
*/
|
||||
#timestamp(ms: number) {
|
||||
const sign = ms < 0 ? "-" : "";
|
||||
ms = Math.abs(ms);
|
||||
|
||||
let s = Math.floor(ms / 1000);
|
||||
let frac = Math.round(ms % 1000);
|
||||
if (frac === 1000) {
|
||||
frac = 0;
|
||||
s += 1;
|
||||
}
|
||||
|
||||
let m = 0;
|
||||
if (s >= 60) {
|
||||
m = Math.floor(s / 60);
|
||||
s %= 60;
|
||||
}
|
||||
|
||||
return html`${
|
||||
sign
|
||||
}${
|
||||
m.toString(10).padStart(2, "0")
|
||||
}:${
|
||||
s.toString(10).padStart(2, "0")
|
||||
}<!--<span class="frac">.${
|
||||
frac.toString(10).padEnd(3, "0")
|
||||
}</span>-->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a time value (in ms) to a px offset from the left edge of the view
|
||||
*/
|
||||
#msToPx(ms: number): number {
|
||||
return this._offsetX + ms / this.msPerPx.get();
|
||||
}
|
||||
|
||||
protected override render = () => {
|
||||
const msPerPx = this.msPerPx.get();
|
||||
const range = this.rangeEnd - this.rangeStart;
|
||||
|
||||
let subdivsPerSec = 32;
|
||||
let subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
let pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
|
||||
while (pxPerSubdiv < 64) {
|
||||
subdivsPerSec /= 2;
|
||||
subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
}
|
||||
|
||||
const msPerSubdiv = msPerPx * pxPerSubdiv;
|
||||
const subdivs = new Array(Math.ceil(subdivsCount) + 2).fill(null);
|
||||
|
||||
const fracPxOffset = this._offsetX % pxPerSubdiv;
|
||||
|
||||
let startingIdx = -Math.floor(this._offsetX / pxPerSubdiv);
|
||||
if (fracPxOffset < 0)
|
||||
startingIdx -= 2;
|
||||
else
|
||||
startingIdx -= 1;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
wrapper: true,
|
||||
panning: this._panning,
|
||||
})}
|
||||
${drag({
|
||||
button: 1,
|
||||
start: this.#onDragStart,
|
||||
move: this.#onDrag,
|
||||
end: this.#onDragEnd,
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="clip-region"
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.clipStart)}px`,
|
||||
right: `${this._renderWidth - this.#msToPx(this.clipEnd)}px`,
|
||||
})}
|
||||
></div>
|
||||
|
||||
${subdivs.map((_, idx) => {
|
||||
const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset;
|
||||
const ms = (startingIdx + idx) * msPerSubdiv;
|
||||
|
||||
const msFrac = Math.abs(Math.round(ms) % 1000);
|
||||
const strong = msFrac === 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
hash: true,
|
||||
strong,
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${pxOffset}px`
|
||||
})}
|
||||
></div>
|
||||
${strong ? html`
|
||||
<span class="timestamp"
|
||||
style=${styleMap({
|
||||
left: `${pxOffset + 8}px`
|
||||
})}
|
||||
>
|
||||
${this.#timestamp(ms)}
|
||||
</span>
|
||||
` : nothing}
|
||||
`;
|
||||
})}
|
||||
|
||||
<div class="playhead"
|
||||
${drag({
|
||||
move: this.#onPlayheadDrag
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.playHead)}px`
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="head"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
|
||||
${repeat(
|
||||
Object.entries(this.keyframes),
|
||||
([id]) => id,
|
||||
([id, keyframe]) => html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
keyframe: true,
|
||||
selected: this._selectedKeyframe === id,
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(keyframe)}px`
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.#onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
></div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
$hierarchy-width: 290px;
|
||||
|
||||
.app-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -8,12 +10,19 @@
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
}
|
||||
.v-end {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
}
|
||||
.v-after-toolbar {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
}
|
||||
.h-start { left: 16px }
|
||||
.h-end { right: 16px }
|
||||
.h-before-hierarchy {
|
||||
right: $hierarchy-width + 32px;
|
||||
}
|
||||
.h-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
@@ -29,7 +38,7 @@ sts-button.active {
|
||||
overflow: auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 290px;
|
||||
width: $hierarchy-width;
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 10px 15px;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import "@storyteller/studio-web/canvas-provider";
|
||||
import "@storyteller/studio-web/viewer";
|
||||
import "@storyteller/studio-web/bvh-viewer";
|
||||
import "@storyteller/studio-web/mixamo-viewer";
|
||||
import "./anim-editor.element";
|
||||
import "./button.element";
|
||||
import "./help.element";
|
||||
import "./icon.element";
|
||||
@@ -77,6 +78,8 @@ export class AppElement extends LitElement {
|
||||
"viewer": () => StudioMode.Viewer,
|
||||
_: () => StudioMode.Viewer,
|
||||
});
|
||||
|
||||
this.#redirectBodyFocus();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): Element | ShadowRoot {
|
||||
@@ -189,6 +192,20 @@ export class AppElement extends LitElement {
|
||||
console.log("Hierarchy:", this.sceneObjects);
|
||||
}
|
||||
|
||||
#redirectBodyFocus(): void {
|
||||
// If user focus lands on the `document.body`, neither the frontend app
|
||||
// nor the Bevy app will properly handle keyboard events. Thie function
|
||||
// runs every tick to ensure that user focus never becomes "stranded" in
|
||||
// this way.
|
||||
if (document.activeElement === document.body) {
|
||||
const canvasId = this.#canvasProvider.value?.canvasId;
|
||||
if (canvasId)
|
||||
document.getElementById(canvasId)?.focus();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.#redirectBodyFocus());
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<sts-canvas-provider
|
||||
class="app-root"
|
||||
@@ -247,6 +264,10 @@ export class AppElement extends LitElement {
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${this.mode === StudioMode.Editor ? html`
|
||||
<sts-anim-editor class="timeline v-end h-start h-before-hierarchy"></sts-anim-editor>
|
||||
` : nothing}
|
||||
|
||||
${this.showHelp ? html`
|
||||
<sts-help class="help v-after-toolbar h-start"></sts-help>
|
||||
` : nothing}
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
background: #0006;
|
||||
color: #FFF;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: background 0.1s linear;
|
||||
transition: {
|
||||
property: background, color;
|
||||
duration: 0.1s;
|
||||
timing-function: linear;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export class HelpElement extends LitElement {
|
||||
${section("Navigation", [
|
||||
{ name: "Pan View", controls: "MMB" },
|
||||
{ name: "Orbit around point", controls: "Alt + LMB" },
|
||||
{ name: "Zoom", controls: ["Alt + RMB", "Mouse Wheel"]},
|
||||
{ name: "Zoom", controls: ["Alt + RMB", "Scroll"]},
|
||||
{ name: "Free fly", controls: "W, A, S, D, E, Q" },
|
||||
{ name: "Free look", controls: ["RMB", "Shift"] },
|
||||
])}
|
||||
@@ -21,6 +21,11 @@ export class HelpElement extends LitElement {
|
||||
{ name: "Select", controls: "LMB" },
|
||||
{ name: "Clear selection", controls: "Esc" },
|
||||
{ name: "Focus selection", controls: "F" },
|
||||
{ name: "Delete selection", controls: "Delete" },
|
||||
])}
|
||||
${section("Animation Timeline", [
|
||||
{ name: "Pan", controls: ["MMB", "Scroll"] },
|
||||
{ name: "Zoom", controls: "Ctrl + Scroll" },
|
||||
])}
|
||||
${isDevEnvironment()
|
||||
? section("Developer", [
|
||||
|
||||
@@ -4,10 +4,18 @@ import { LitElement, TemplateResult, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
|
||||
|
||||
import farCrosshairs from "@fortawesome/fontawesome-pro/svgs/regular/crosshairs.svg?raw";
|
||||
import farVideo from "@fortawesome/fontawesome-pro/svgs/regular/video.svg?raw";
|
||||
import fasCircleInfo from "@fortawesome/fontawesome-pro/svgs/regular/circle-info.svg?raw";
|
||||
import fasKey from "@fortawesome/fontawesome-pro/svgs/solid/key.svg?raw";
|
||||
import fasCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw";
|
||||
import fasGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw";
|
||||
import fasUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw";
|
||||
import fasPlay from "@fortawesome/fontawesome-pro/svgs/solid/play.svg?raw";
|
||||
import fasFwdStep from "@fortawesome/fontawesome-pro/svgs/solid/forward-step.svg?raw";
|
||||
import fasFwdFast from "@fortawesome/fontawesome-pro/svgs/solid/forward-fast.svg?raw";
|
||||
import fasBwdStep from "@fortawesome/fontawesome-pro/svgs/solid/backward-step.svg?raw";
|
||||
import fasBwdFast from "@fortawesome/fontawesome-pro/svgs/solid/backward-fast.svg?raw";
|
||||
import fasRotate from "@fortawesome/fontawesome-pro/svgs/regular/arrows-rotate.svg?raw";
|
||||
import fasScale from "@fortawesome/fontawesome-pro/svgs/regular/arrow-up-right-and-arrow-down-left-from-center.svg?raw";
|
||||
import fasCaretRight from "@fortawesome/fontawesome-pro/svgs/solid/caret-right.svg?raw";
|
||||
@@ -37,13 +45,21 @@ function renderSvg(iconId: string) {
|
||||
return RENDER_RESULTS.get(iconId)!;
|
||||
|
||||
const svgSrc = match (iconId, {
|
||||
"bwd-fast": () => fasBwdFast,
|
||||
"bwd-step": () => fasBwdStep,
|
||||
"circle-info": () => fasCircleInfo,
|
||||
"caret-right": () => fasCaretRight,
|
||||
"crosshairs": () => farCrosshairs,
|
||||
"cube": () => fasCube,
|
||||
"fwd-fast": () => fasFwdFast,
|
||||
"fwd-step": () => fasFwdStep,
|
||||
"key": () => fasKey,
|
||||
"globe": () => fasGlobe,
|
||||
"up-down-left-right": () => fasUpDownLeftRight,
|
||||
"play": () => fasPlay,
|
||||
"rotate": () => fasRotate,
|
||||
"scale": () => fasScale,
|
||||
"up-down-left-right": () => fasUpDownLeftRight,
|
||||
"video": () => farVideo,
|
||||
_: () => null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$item-height: 24px;
|
||||
|
||||
:host, .children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -31,7 +33,7 @@ header {
|
||||
timing-function: linear;
|
||||
}
|
||||
|
||||
&:hover:not(:has(.toggle-expanded:hover)) {
|
||||
&.selectable:hover:not(:has(.toggle-expanded:hover)) {
|
||||
background: #0000001F;
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ header {
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
height: $item-height;
|
||||
margin-block: -5px;
|
||||
padding-inline-start: 4px;
|
||||
|
||||
@@ -72,12 +74,13 @@ header {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.name {
|
||||
.label {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
padding: {
|
||||
block: 5px;
|
||||
inline: 5px 10px;
|
||||
}
|
||||
height: $item-height;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline-start: 5px;
|
||||
}
|
||||
|
||||
.children {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type EntitySelectEvent } from "@storyteller/studio";
|
||||
import { LitElement, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import "./icon.element";
|
||||
|
||||
@@ -20,8 +21,9 @@ export class TreeElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() name = "";
|
||||
@property() treeChildren?: Tree[];
|
||||
@property() expanded = false;
|
||||
@property() icon?: string;
|
||||
@property({ attribute: false }) treeChildren?: Tree[];
|
||||
@property({ type: Boolean }) expanded = false;
|
||||
|
||||
@state() selected = false;
|
||||
|
||||
@@ -35,12 +37,18 @@ export class TreeElement extends LitElement {
|
||||
}
|
||||
|
||||
selectEntity(): void {
|
||||
studio.select(this.id);
|
||||
if (this.id)
|
||||
studio.select(this.id);
|
||||
}
|
||||
|
||||
// TODO: A11y
|
||||
protected override render = () => html`
|
||||
<header class=${classMap({ selected: this.selected })}>
|
||||
<header
|
||||
class=${classMap({
|
||||
selected: this.selected,
|
||||
selectable: !!this.id,
|
||||
})}
|
||||
>
|
||||
${this.treeChildren?.length ? html`
|
||||
<button class="toggle-expanded"
|
||||
@click=${this.toggleExpanded}
|
||||
@@ -55,9 +63,12 @@ export class TreeElement extends LitElement {
|
||||
></sts-icon>
|
||||
</button>
|
||||
` : nothing}
|
||||
<span class="name"
|
||||
<span class="label"
|
||||
@click=${this.selectEntity}
|
||||
>
|
||||
${this.icon ? html`
|
||||
<sts-icon icon=${this.icon}></sts-icon>
|
||||
` : nothing}
|
||||
${this.name}
|
||||
</span>
|
||||
</header>
|
||||
@@ -66,7 +77,7 @@ export class TreeElement extends LitElement {
|
||||
<div class="children">
|
||||
${this.treeChildren?.map(tree => html`
|
||||
<sts-tree
|
||||
id=${tree.id}
|
||||
id=${ifDefined(tree.id)}
|
||||
.name=${tree.name}
|
||||
.treeChildren=${tree.children}
|
||||
></sts-tree>
|
||||
|
||||
@@ -80,6 +80,7 @@ export class CanvasProviderElement
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = this.#canvasId;
|
||||
canvas.slot = "canvas";
|
||||
canvas.tabIndex = -1;
|
||||
|
||||
this.appendChild(canvas);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ bevy_mod_outline = "0.6.0"
|
||||
bevy_web_asset = "0.7.0"
|
||||
bitflags = "2.4.0"
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
itertools = "0.12.1"
|
||||
js-sys = { version = "0.3.65", optional = true }
|
||||
parking_lot = "0.12.1"
|
||||
serde = { version = "1.0.192", optional = true }
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use bevy::prelude::*;
|
||||
use itertools::Itertools;
|
||||
use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
|
||||
use crate::math::{InvLerp, Lerp};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
pub struct CameraAnimPlugin;
|
||||
impl Plugin for CameraAnimPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<CameraKeyframe>()
|
||||
.editor_registry::<CameraKeyframe>()
|
||||
.register_type::<CameraTargetKeyframe>()
|
||||
.editor_registry::<CameraTargetKeyframe>();
|
||||
|
||||
app.add_systems(Update, draw_camera_keyframes);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
wasm::spawn_camera_keyframes,
|
||||
wasm::notify_keyframe_changes.map(bevy::utils::error),
|
||||
wasm::process_update_keyframe,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraKeyframe(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraTargetKeyframe(pub f32);
|
||||
|
||||
fn draw_camera_keyframes(
|
||||
mut gizmos: Gizmos,
|
||||
r_seek_time: Res<GlobalSeekTime>,
|
||||
q_cam_keyframe_xforms: Query<&GlobalTransform, With<CameraKeyframe>>,
|
||||
q_cam_target_keyframe_xforms: Query<&GlobalTransform, With<CameraTargetKeyframe>>,
|
||||
q_cam_keyframes: Query<(&CameraKeyframe, &GlobalTransform)>,
|
||||
) {
|
||||
if q_cam_keyframes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for world_xform in q_cam_keyframe_xforms
|
||||
.iter()
|
||||
.chain(q_cam_target_keyframe_xforms.iter())
|
||||
{
|
||||
let origin = world_xform.translation();
|
||||
let x_axis = world_xform.right() * 0.25;
|
||||
let y_axis = world_xform.up() * 0.25;
|
||||
let z_axis = world_xform.back() * 0.25;
|
||||
|
||||
gizmos.line(origin, origin + x_axis, Color::RED);
|
||||
gizmos.line(origin, origin + y_axis, Color::GREEN);
|
||||
gizmos.line(origin, origin + z_axis, Color::BLUE);
|
||||
}
|
||||
|
||||
let seek_time = **r_seek_time;
|
||||
let keyframes_sorted = q_cam_keyframes
|
||||
.iter()
|
||||
.sorted_by(|(a, _), (b, _)| a.partial_cmp(&b.0).unwrap())
|
||||
.collect_vec();
|
||||
|
||||
let (timestamp_first, xform_first) = keyframes_sorted.first().unwrap();
|
||||
let (timestamp_last, xform_last) = keyframes_sorted.last().unwrap();
|
||||
|
||||
if seek_time <= timestamp_first.0 {
|
||||
let (_, rot, pos) = xform_first.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if seek_time >= timestamp_last.0 {
|
||||
let (_, rot, pos) = xform_last.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if let Some(((timestamp_a, xform_a), (timestamp_b, xform_b))) = keyframes_sorted
|
||||
.iter()
|
||||
.tuple_windows()
|
||||
.find(|((a, _), (b, _))| a.0 <= seek_time && b.0 >= seek_time)
|
||||
{
|
||||
let t = seek_time.inv_lerp(timestamp_a.0, timestamp_b.0);
|
||||
let (_, rot_a, pos_a) = xform_a.to_scale_rotation_translation();
|
||||
let (_, rot_b, pos_b) = xform_b.to_scale_rotation_translation();
|
||||
let pos = t.lerp(pos_a, pos_b);
|
||||
let rot = Quat::slerp(rot_a, rot_b, t);
|
||||
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use space_editor::prelude::space_undo::{self, AddedEntity};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{
|
||||
camera_controller::CameraController,
|
||||
wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer},
|
||||
};
|
||||
|
||||
use super::{CameraKeyframe, CameraTargetKeyframe};
|
||||
|
||||
static ADD_KEYFRAME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
static UPDATE_KEYFRAME: WasmMessageBuffer<(Entity, f32)> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = addCameraKeyframe)]
|
||||
pub fn add_camera_keyframe(timestamp: f32) {
|
||||
ADD_KEYFRAME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_camera_keyframes(
|
||||
mut cmd: Commands,
|
||||
mut ew_changes: EventWriter<space_undo::NewChange>,
|
||||
q_camera: Query<(&CameraController, &GlobalTransform)>,
|
||||
) {
|
||||
if let Some(timestamp) = ADD_KEYFRAME.take_message() {
|
||||
let Ok((cam_controller, world_xform)) = q_camera.get_single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Target Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from_translation(
|
||||
cam_controller.focus,
|
||||
)),
|
||||
CameraTargetKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from(*world_xform)),
|
||||
CameraKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = updateKeyframe)]
|
||||
pub fn update_keyframe(entity: &str, timestamp: f32) {
|
||||
let entity = Entity::from_str(entity).unwrap();
|
||||
UPDATE_KEYFRAME.write_message((entity, timestamp));
|
||||
}
|
||||
|
||||
pub(super) fn process_update_keyframe(
|
||||
mut q_camera_keyframes: Query<&mut CameraKeyframe>,
|
||||
mut q_camera_target_keyframes: Query<&mut CameraTargetKeyframe>,
|
||||
) {
|
||||
if let Some((ent, timestamp)) = UPDATE_KEYFRAME.take_message() {
|
||||
if let Ok(mut keyframe) = q_camera_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else if let Ok(mut keyframe) = q_camera_target_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else {
|
||||
warn!("No matching keyframe found for entity: {ent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const CAMERA_KEYFRAMES_EVENT: &str = r#"
|
||||
export interface CameraKeyframesEvent extends CustomEvent {
|
||||
type: "camera-keyframes";
|
||||
detail: Record<string, number>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"camera-keyframes": CameraKeyframesEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn notify_keyframe_changes(
|
||||
mut l_notified: Local<serde_json::Map<String, serde_json::Value>>,
|
||||
q_keyframes: Query<(Entity, &CameraKeyframe)>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if l_notified.len() == q_keyframes.iter().count() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
l_notified.clear();
|
||||
for (ent, timestamp) in q_keyframes.iter() {
|
||||
l_notified.insert(format!("{ent:?}"), timestamp.0.into());
|
||||
}
|
||||
|
||||
wasm::dispatch_to_js(window, "camera-keyframes", &*l_notified)
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -9,6 +9,7 @@ use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
use crate::hierarchy;
|
||||
pub use retargeting::*;
|
||||
|
||||
mod camera;
|
||||
mod retargeting;
|
||||
|
||||
pub struct AnimPlugin;
|
||||
@@ -28,11 +29,19 @@ impl Plugin for AnimPlugin {
|
||||
.editor_registry::<InvBindPose>();
|
||||
|
||||
app.add_systems(Update, process_skinned_meshes);
|
||||
app.insert_resource(GlobalSeekTime(0.));
|
||||
|
||||
app.add_plugins(retargeting::RetargetingPlugin);
|
||||
app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin));
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(Update, wasm::update_global_seek_time);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
pub struct GlobalSeekTime(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct Skeleton;
|
||||
@@ -139,3 +148,26 @@ fn process_skinned_meshes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
static SEEK_TIME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = seekTime)]
|
||||
pub fn seek_time(timestamp: f32) {
|
||||
SEEK_TIME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn update_global_seek_time(mut r_seek_time: ResMut<GlobalSeekTime>) {
|
||||
if let Some(timestamp) = SEEK_TIME.take_message() {
|
||||
**r_seek_time = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ pub enum Selection {
|
||||
Clear,
|
||||
/// Focus the camera on the currently selected entity
|
||||
Focus,
|
||||
/// Delete the currently selected entity and its descendants
|
||||
Delete,
|
||||
}
|
||||
impl From<Selection> for Action {
|
||||
fn from(value: Selection) -> Self {
|
||||
@@ -100,6 +102,12 @@ fn translate_selection_events(keyboard: Res<Input<KeyCode>>, mut input: EventWri
|
||||
} else if keyboard.just_released(KeyCode::F) {
|
||||
input.send(InputAction(Lifecycle::Stop, Selection::Focus.into()));
|
||||
}
|
||||
|
||||
if keyboard.just_pressed(KeyCode::Delete) {
|
||||
input.send(InputAction(Lifecycle::Start, Selection::Delete.into()));
|
||||
} else if keyboard.just_released(KeyCode::Delete) {
|
||||
input.send(InputAction(Lifecycle::Stop, Selection::Delete.into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_debug_toggle(keyboard: Res<Input<KeyCode>>, mut input: EventWriter<InputAction>) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface EntityMultiSpawnEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"entity-spawn": EntitySpawnEvent;
|
||||
"entity-despawn": EntityDespawnEvent;
|
||||
"entity-multi-spawn": EntityMultiSpawnEvent;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{prelude::*, render::mesh::skinning::SkinnedMesh};
|
||||
use bevy_mod_outline::{
|
||||
AutoGenerateOutlineNormalsPlugin, OutlineBundle, OutlinePlugin, OutlineVolume,
|
||||
};
|
||||
use bevy_mod_picking::{prelude::Pointer, selection::Select, PickableBundle};
|
||||
use space_editor::prelude::space_undo::{self, NewChange, RemovedEntity};
|
||||
|
||||
use crate::{
|
||||
input::{self, Action, InputAction, Lifecycle},
|
||||
@@ -13,7 +16,13 @@ pub struct SelectionPlugin;
|
||||
impl Plugin for SelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((OutlinePlugin, AutoGenerateOutlineNormalsPlugin))
|
||||
.add_systems(Update, update_selection)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_selection,
|
||||
process_delete_inputs.run_if(resource_exists::<Selection>()),
|
||||
),
|
||||
)
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
|
||||
@@ -149,6 +158,28 @@ fn update_selection(
|
||||
}
|
||||
}
|
||||
|
||||
fn process_delete_inputs(
|
||||
mut cmd: Commands,
|
||||
mut er_input: EventReader<InputAction>,
|
||||
mut ew_changes: EventWriter<space_undo::NewChange>,
|
||||
r_selection: Res<Selection>,
|
||||
) {
|
||||
if er_input.read().any(|action| {
|
||||
matches!(
|
||||
action,
|
||||
InputAction(Lifecycle::Stop, Action::Selection(input::Selection::Delete))
|
||||
)
|
||||
}) {
|
||||
let entity = **r_selection;
|
||||
|
||||
cmd.entity(entity).despawn_recursive();
|
||||
cmd.remove_resource::<Selection>();
|
||||
ew_changes.send(NewChange {
|
||||
change: Arc::new(RemovedEntity { entity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_selection_outline(
|
||||
mut cmd: Commands,
|
||||
q_selected: Query<&Children, With<Selected>>,
|
||||
@@ -193,7 +224,7 @@ mod wasm {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{Selected, Selection};
|
||||
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
|
||||
use crate::wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const EVENT_DECLARATION: &str = r#"
|
||||
@@ -207,7 +238,7 @@ export interface EntitySelectEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"entity-select": EntitySelectEvent;
|
||||
}
|
||||
}
|
||||
@@ -225,12 +256,7 @@ declare global {
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn select(entity: &str) {
|
||||
let mut split = entity.split('v');
|
||||
let index = split.next().unwrap().parse::<u32>().unwrap();
|
||||
let generation = split.next().unwrap().parse::<u32>().unwrap();
|
||||
let bits = (generation as u64) << 32 | index as u64;
|
||||
|
||||
let entity = Entity::from_bits(bits);
|
||||
let entity = Entity::from_str(entity).unwrap();
|
||||
SELECTION_REQUEST.write_message(entity);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -460,7 +460,7 @@ export interface SceneStateEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"scene-state": SceneStateEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ impl AsJson for GlobalTransform {
|
||||
}
|
||||
}
|
||||
|
||||
impl AsJson for serde_json::Map<String, JsonValue> {
|
||||
fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsJsValue for T
|
||||
where
|
||||
T: AsJson,
|
||||
@@ -138,3 +144,38 @@ impl AsJsValue for u32 {
|
||||
Ok(JsValue::from_f64(*self as f64))
|
||||
}
|
||||
}
|
||||
|
||||
/// A crate-local version of `std::str::FromStr` that can be implemented for
|
||||
/// foreign types
|
||||
pub trait FromStr: Sized {
|
||||
type Err;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err>;
|
||||
}
|
||||
|
||||
impl FromStr for Entity {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let fmt_err =
|
||||
|| format!("Expected a string with format '<integer>v<integer>', found '{s}'");
|
||||
|
||||
let mut split = s.split('v');
|
||||
|
||||
let index = split
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| fmt_err())?;
|
||||
|
||||
let generation = split
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| fmt_err())?;
|
||||
|
||||
let bits = (generation as u64) << 32 | index as u64;
|
||||
|
||||
Ok(Entity::from_bits(bits))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user