Feat: Implement time transport controls for viewport simulation

This commit is contained in:
2026-04-27 19:22:11 +02:00
parent 05ec0f1552
commit bc3aa24c83

View File

@@ -1,4 +1,11 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent
} from "react";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets";
@@ -42,6 +49,7 @@ interface ViewportCanvasProps {
world: WorldSettings;
sceneDocument: SceneDocument;
editorSimulationController: EditorSimulationController;
editorSimulationPlaying?: boolean;
projectAssets: Record<string, ProjectAssetRecord>;
loadedModelAssets: Record<string, LoadedModelAsset>;
loadedImageAssets: Record<string, LoadedImageAsset>;
@@ -71,6 +79,187 @@ interface ViewportCanvasProps {
onTransformPreviewChange?(transformSession: ActiveTransformSession): void;
onTransformCommit(transformSession: ActiveTransformSession): void;
onTransformCancel(): void;
onPlayEditorSimulation?(): void;
onPauseEditorSimulation?(): void;
onStepEditorSimulation?(deltaHours: number): void;
}
const VIEWPORT_TIME_TRANSPORT_STEP_HOURS = 0.25;
const VIEWPORT_TIME_TRANSPORT_REPEAT_MS = 125;
interface ViewportTimeTransportProps {
panelId: ViewportPanelId;
editorSimulationPlaying: boolean;
onPlayEditorSimulation(): void;
onPauseEditorSimulation(): void;
onStepEditorSimulation(deltaHours: number): void;
}
function ViewportTimeTransport({
panelId,
editorSimulationPlaying,
onPlayEditorSimulation,
onPauseEditorSimulation,
onStepEditorSimulation
}: ViewportTimeTransportProps) {
const repeatIntervalRef = useRef<number | null>(null);
const activePointerIdRef = useRef<number | null>(null);
const activeButtonRef = useRef<HTMLButtonElement | null>(null);
const pointerStepHandledRef = useRef(false);
const latestStepHandlerRef = useRef(onStepEditorSimulation);
useEffect(() => {
latestStepHandlerRef.current = onStepEditorSimulation;
}, [onStepEditorSimulation]);
const stopStepping = useCallback(() => {
if (repeatIntervalRef.current !== null) {
window.clearInterval(repeatIntervalRef.current);
repeatIntervalRef.current = null;
}
const activeButton = activeButtonRef.current;
const activePointerId = activePointerIdRef.current;
if (
activeButton !== null &&
activePointerId !== null &&
activeButton.hasPointerCapture?.(activePointerId)
) {
activeButton.releasePointerCapture(activePointerId);
}
activePointerIdRef.current = null;
activeButtonRef.current = null;
}, []);
useEffect(() => {
const handleWindowBlur = () => {
stopStepping();
};
window.addEventListener("blur", handleWindowBlur);
return () => {
window.removeEventListener("blur", handleWindowBlur);
stopStepping();
};
}, [stopStepping]);
const step = useCallback((direction: -1 | 1) => {
latestStepHandlerRef.current(
direction * VIEWPORT_TIME_TRANSPORT_STEP_HOURS
);
}, []);
const startStepping = useCallback(
(direction: -1 | 1, event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
stopStepping();
pointerStepHandledRef.current = true;
activePointerIdRef.current = event.pointerId;
activeButtonRef.current = event.currentTarget;
event.currentTarget.setPointerCapture?.(event.pointerId);
step(direction);
repeatIntervalRef.current = window.setInterval(() => {
step(direction);
}, VIEWPORT_TIME_TRANSPORT_REPEAT_MS);
},
[step, stopStepping]
);
const stopPointerStepping = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
if (
activePointerIdRef.current !== null &&
event.pointerId !== activePointerIdRef.current
) {
return;
}
event.stopPropagation();
stopStepping();
},
[stopStepping]
);
const clickStep = useCallback((direction: -1 | 1) => {
if (pointerStepHandledRef.current) {
pointerStepHandledRef.current = false;
return;
}
step(direction);
}, [step]);
return (
<div
className="viewport-canvas__time-transport"
data-testid={`viewport-time-transport-${panelId}`}
role="group"
aria-label="Editor project time transport"
>
<button
className="viewport-canvas__time-button"
type="button"
data-testid={`viewport-time-rewind-${panelId}`}
aria-label="Step editor project time backward"
title="Step editor project time backward"
onPointerDown={(event) => startStepping(-1, event)}
onPointerUp={stopPointerStepping}
onPointerCancel={stopPointerStepping}
onLostPointerCapture={stopPointerStepping}
onBlur={stopStepping}
onClick={() => clickStep(-1)}
>
&lt;&lt;
</button>
<button
className="viewport-canvas__time-button viewport-canvas__time-button--primary"
type="button"
data-testid={`viewport-time-play-toggle-${panelId}`}
aria-label={
editorSimulationPlaying
? "Pause editor project time"
: "Play editor project time"
}
title={
editorSimulationPlaying
? "Pause editor project time"
: "Play editor project time"
}
onClick={
editorSimulationPlaying
? onPauseEditorSimulation
: onPlayEditorSimulation
}
>
{editorSimulationPlaying ? "II" : ">"}
</button>
<button
className="viewport-canvas__time-button"
type="button"
data-testid={`viewport-time-forward-${panelId}`}
aria-label="Step editor project time forward"
title="Step editor project time forward"
onPointerDown={(event) => startStepping(1, event)}
onPointerUp={stopPointerStepping}
onPointerCancel={stopPointerStepping}
onLostPointerCapture={stopPointerStepping}
onBlur={stopStepping}
onClick={() => clickStep(1)}
>
&gt;&gt;
</button>
</div>
);
}
export function ViewportCanvas({
@@ -78,6 +267,7 @@ export function ViewportCanvas({
world,
sceneDocument,
editorSimulationController,
editorSimulationPlaying = false,
projectAssets,
loadedModelAssets,
loadedImageAssets,
@@ -106,7 +296,10 @@ export function ViewportCanvas({
onTransformSessionChange,
onTransformPreviewChange = () => undefined,
onTransformCommit,
onTransformCancel
onTransformCancel,
onPlayEditorSimulation = () => undefined,
onPauseEditorSimulation = () => undefined,
onStepEditorSimulation = () => undefined
}: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null);
@@ -429,6 +622,16 @@ export function ViewportCanvas({
</div>
)}
{!isActivePanel ? null : (
<ViewportTimeTransport
panelId={panelId}
editorSimulationPlaying={editorSimulationPlaying}
onPlayEditorSimulation={onPlayEditorSimulation}
onPauseEditorSimulation={onPauseEditorSimulation}
onStepEditorSimulation={onStepEditorSimulation}
/>
)}
{viewportMessage === null ? null : (
<div className="viewport-canvas__fallback" role="status">
<div className="viewport-canvas__fallback-title">