diff --git a/Cargo.lock b/Cargo.lock index d6bb0a1..66bf9a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/framework/src/index.ts b/framework/src/index.ts index 6b91947..7c8a3ee 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -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"; diff --git a/framework/src/lib/di/events.ts b/framework/src/lib/di/events.ts index 629d5c8..dc02945 100644 --- a/framework/src/lib/di/events.ts +++ b/framework/src/lib/di/events.ts @@ -65,7 +65,7 @@ export class ProviderRemoval extends CustomEvent> { } declare global { - interface HTMLElementEventMap { + interface GlobalEventHandlersEventMap { [DIEvent.InjectionRequest]: InjectionRequest; [DIEvent.DependencyProvision]: DependencyProvision; [DIEvent.ProviderRemoval]: ProviderRemoval; diff --git a/framework/src/lib/directives/drag.ts b/framework/src/lib/directives/drag.ts new file mode 100644 index 0000000..586b9b8 --- /dev/null +++ b/framework/src/lib/directives/drag.ts @@ -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); diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss new file mode 100644 index 0000000..a041364 --- /dev/null +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -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; +} diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts new file mode 100644 index 0000000..b108990 --- /dev/null +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -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 = {}; + + protected override update(changes: PropertyValues): 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): void { + this.timelineStart = event.detail; + } + + #onTimelineEndChange(event: CustomEvent): void { + this.timelineEnd = event.detail; + } + + #onPlayHeadChange(event: CustomEvent): void { + this.playHead = event.detail; + } + + #onKeyframesChange(event: CustomEvent>): void { + this.keyframes = event.detail; + } + + protected override render = () => html` + + +
+ + + `; +} diff --git a/studio-frontend/src/app/anim-timeline.element.scss b/studio-frontend/src/app/anim-timeline.element.scss new file mode 100644 index 0000000..fa1b681 --- /dev/null +++ b/studio-frontend/src/app/anim-timeline.element.scss @@ -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 + } +} diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts new file mode 100644 index 0000000..3d95fdd --- /dev/null +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -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 = {}; + + @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): 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") + }`; + } + + /** + * 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` +
+
+ + ${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` +
+ ${strong ? html` + + ${this.#timestamp(ms)} + + ` : nothing} + `; + })} + +
+
+
+
+ + ${repeat( + Object.entries(this.keyframes), + ([id]) => id, + ([id, keyframe]) => html` +
this.#onKeyframeDrag(id, event), + })} + @click=${() => this.selectKeyframe(id)} + >
+ ` + )} +
+ `; + } +} diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 640c3d6..fc4a85b 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -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; diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 989d64f..2bc2427 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -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` ` : nothing} + ${this.mode === StudioMode.Editor ? html` + + ` : nothing} + ${this.showHelp ? html` ` : nothing} diff --git a/studio-frontend/src/app/button.element.scss b/studio-frontend/src/app/button.element.scss index fc79cde..085f9a9 100644 --- a/studio-frontend/src/app/button.element.scss +++ b/studio-frontend/src/app/button.element.scss @@ -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) { diff --git a/studio-frontend/src/app/help.element.ts b/studio-frontend/src/app/help.element.ts index 4cbef5c..dbf609c 100644 --- a/studio-frontend/src/app/help.element.ts +++ b/studio-frontend/src/app/help.element.ts @@ -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", [ diff --git a/studio-frontend/src/app/icon.element.ts b/studio-frontend/src/app/icon.element.ts index 9788b68..668a344 100644 --- a/studio-frontend/src/app/icon.element.ts +++ b/studio-frontend/src/app/icon.element.ts @@ -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, }); diff --git a/studio-frontend/src/app/tree.element.scss b/studio-frontend/src/app/tree.element.scss index c5a2d87..82f5558 100644 --- a/studio-frontend/src/app/tree.element.scss +++ b/studio-frontend/src/app/tree.element.scss @@ -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 { diff --git a/studio-frontend/src/app/tree.element.ts b/studio-frontend/src/app/tree.element.ts index 64008d0..3911fcc 100644 --- a/studio-frontend/src/app/tree.element.ts +++ b/studio-frontend/src/app/tree.element.ts @@ -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` -
+
${this.treeChildren?.length ? html` ` : nothing} - + ${this.icon ? html` + + ` : nothing} ${this.name}
@@ -66,7 +77,7 @@ export class TreeElement extends LitElement {
${this.treeChildren?.map(tree => html` diff --git a/studio-web/src/lib/canvas-provider.element.ts b/studio-web/src/lib/canvas-provider.element.ts index 9a56fed..a8a18c9 100644 --- a/studio-web/src/lib/canvas-provider.element.ts +++ b/studio-web/src/lib/canvas-provider.element.ts @@ -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); diff --git a/studio/Cargo.toml b/studio/Cargo.toml index 7387d17..62dd252 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -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 } diff --git a/studio/src/anim/camera.rs b/studio/src/anim/camera.rs new file mode 100644 index 0000000..eab3be6 --- /dev/null +++ b/studio/src/anim/camera.rs @@ -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::() + .editor_registry::() + .register_type::() + .editor_registry::(); + + 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, + q_cam_keyframe_xforms: Query<&GlobalTransform, With>, + q_cam_target_keyframe_xforms: Query<&GlobalTransform, With>, + 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 = 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, + 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; +} + +declare global { + export interface GlobalEventHandlersEventMap { + "camera-keyframes": CameraKeyframesEvent; + } +} + "#; + + pub(super) fn notify_keyframe_changes( + mut l_notified: Local>, + q_keyframes: Query<(Entity, &CameraKeyframe)>, + q_window: Query<&Window, With>, + ) -> 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) + } +} diff --git a/studio/src/anim/mod.rs b/studio/src/anim/mod.rs index 1f03b7c..6c512fb 100644 --- a/studio/src/anim/mod.rs +++ b/studio/src/anim/mod.rs @@ -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::(); 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 = 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) { + if let Some(timestamp) = SEEK_TIME.take_message() { + **r_seek_time = timestamp; + } + } +} diff --git a/studio/src/input.rs b/studio/src/input.rs index 30add81..32882e2 100644 --- a/studio/src/input.rs +++ b/studio/src/input.rs @@ -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 for Action { fn from(value: Selection) -> Self { @@ -100,6 +102,12 @@ fn translate_selection_events(keyboard: Res>, 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>, mut input: EventWriter) { diff --git a/studio/src/inspector/wasm.rs b/studio/src/inspector/wasm.rs index 6622087..66c2548 100644 --- a/studio/src/inspector/wasm.rs +++ b/studio/src/inspector/wasm.rs @@ -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; diff --git a/studio/src/interaction/selection.rs b/studio/src/interaction/selection.rs index f73a138..3c42ae9 100644 --- a/studio/src/interaction/selection.rs +++ b/studio/src/interaction/selection.rs @@ -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::()), + ), + ) .add_systems( PostUpdate, draw_selection_outline.run_if(resource_changed_or_removed::()), @@ -149,6 +158,28 @@ fn update_selection( } } +fn process_delete_inputs( + mut cmd: Commands, + mut er_input: EventReader, + mut ew_changes: EventWriter, + r_selection: Res, +) { + 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::(); + ew_changes.send(NewChange { + change: Arc::new(RemovedEntity { entity }), + }); + } +} + fn draw_selection_outline( mut cmd: Commands, q_selected: Query<&Children, With>, @@ -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::().unwrap(); - let generation = split.next().unwrap().parse::().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); } diff --git a/studio/src/scene.rs b/studio/src/scene.rs index 6649c4d..2f04039 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene.rs @@ -460,7 +460,7 @@ export interface SceneStateEvent extends CustomEvent { } declare global { - export interface HTMLElementEventMap { + export interface GlobalEventHandlersEventMap { "scene-state": SceneStateEvent; } } diff --git a/studio/src/wasm/conv.rs b/studio/src/wasm/conv.rs index e7587dd..b7c4af3 100644 --- a/studio/src/wasm/conv.rs +++ b/studio/src/wasm/conv.rs @@ -83,6 +83,12 @@ impl AsJson for GlobalTransform { } } +impl AsJson for serde_json::Map { + fn as_json(&self) -> JsonValue { + JsonValue::Object(self.clone()) + } +} + impl 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; +} + +impl FromStr for Entity { + type Err = String; + + fn from_str(s: &str) -> Result { + let fmt_err = + || format!("Expected a string with format 'v', found '{s}'"); + + let mut split = s.split('v'); + + let index = split + .next() + .unwrap() + .parse::() + .map_err(|_| fmt_err())?; + + let generation = split + .next() + .unwrap() + .parse::() + .map_err(|_| fmt_err())?; + + let bits = (generation as u64) << 32 | index as u64; + + Ok(Entity::from_bits(bits)) + } +}