1ab6ffd593
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
import {
|
|
emptyTokenPathSessionState,
|
|
tokenPathKey,
|
|
type TokenPathPlaybackEntry,
|
|
type TokenPathSessionEvent,
|
|
type TokenPathSessionState,
|
|
type TokenPathTargetRef,
|
|
} from '../../shared/types/tokenPathSession';
|
|
|
|
function bump(
|
|
state: TokenPathSessionState,
|
|
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
|
|
): TokenPathSessionState {
|
|
return {
|
|
...state,
|
|
...patch,
|
|
revision: state.revision + 1,
|
|
serverNowMs: Date.now(),
|
|
};
|
|
}
|
|
|
|
export class TokenPathSessionStore {
|
|
private state: TokenPathSessionState = emptyTokenPathSessionState();
|
|
|
|
getState(): TokenPathSessionState {
|
|
return this.state;
|
|
}
|
|
|
|
/** Refresh clock without logical change (optional heartbeat). */
|
|
touchClock(): TokenPathSessionState {
|
|
this.state = { ...this.state, serverNowMs: Date.now() };
|
|
return this.state;
|
|
}
|
|
|
|
reset(): TokenPathSessionState {
|
|
if (
|
|
Object.keys(this.state.playback).length === 0 &&
|
|
Object.keys(this.state.presentationVisible).length === 0
|
|
) {
|
|
return this.state;
|
|
}
|
|
this.state = emptyTokenPathSessionState(this.state.revision + 1);
|
|
return this.state;
|
|
}
|
|
|
|
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
|
|
switch (event.kind) {
|
|
case 'clear':
|
|
return this.reset();
|
|
case 'showPresentation': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
if (this.state.presentationVisible[key]) return this.state;
|
|
this.state = bump(this.state, {
|
|
presentationVisible: { ...this.state.presentationVisible, [key]: true },
|
|
});
|
|
return this.state;
|
|
}
|
|
case 'hidePresentation': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
if (!this.state.presentationVisible[key]) return this.state;
|
|
const { [key]: _removed, ...rest } = this.state.presentationVisible;
|
|
this.state = bump(this.state, { presentationVisible: rest });
|
|
return this.state;
|
|
}
|
|
case 'stop': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
const prev = this.state.playback[key];
|
|
if (!prev || prev.phase === 'stopped') return this.state;
|
|
const atDist =
|
|
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
|
|
? Math.max(0, event.atDist)
|
|
: prev.baseDist;
|
|
this.state = bump(this.state, {
|
|
playback: {
|
|
...this.state.playback,
|
|
[key]: {
|
|
...prev,
|
|
phase: 'stopped',
|
|
baseDist: atDist,
|
|
rejoinDist: null,
|
|
segmentStartedAtMs: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
return this.state;
|
|
}
|
|
case 'resume': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
const prev = this.state.playback[key];
|
|
if (!prev) return this.state;
|
|
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
|
|
const entry: TokenPathPlaybackEntry = {
|
|
...prev,
|
|
phase: 'moving',
|
|
segmentStartedAtMs: Date.now(),
|
|
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
|
|
baseDist: Math.max(0, event.fromDist),
|
|
rejoinDist: null,
|
|
direction: 1,
|
|
};
|
|
this.state = bump(this.state, {
|
|
playback: { ...this.state.playback, [key]: entry },
|
|
});
|
|
return this.state;
|
|
}
|
|
case 'resetToStart': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
const prev = this.state.playback[key];
|
|
const entry: TokenPathPlaybackEntry = {
|
|
kind: event.target.kind,
|
|
placementId: event.target.placementId,
|
|
phase: 'moving',
|
|
segmentStartedAtMs: Date.now(),
|
|
baseDist: 0,
|
|
direction: 1,
|
|
rejoinDist: null,
|
|
durationSec: prev?.durationSec ?? 8,
|
|
pathLength: prev?.pathLength ?? 1,
|
|
};
|
|
this.state = bump(this.state, {
|
|
playback: { ...this.state.playback, [key]: entry },
|
|
});
|
|
return this.state;
|
|
}
|
|
case 'seedPlayback': {
|
|
const e = event.entry;
|
|
const key = tokenPathKey(e.kind, e.placementId);
|
|
const entry: TokenPathPlaybackEntry = {
|
|
kind: e.kind,
|
|
placementId: e.placementId,
|
|
phase: e.phase,
|
|
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
|
|
baseDist: e.baseDist,
|
|
direction: e.direction,
|
|
rejoinDist: e.rejoinDist,
|
|
durationSec: e.durationSec,
|
|
pathLength: e.pathLength,
|
|
};
|
|
this.state = bump(this.state, {
|
|
playback: { ...this.state.playback, [key]: entry },
|
|
});
|
|
return this.state;
|
|
}
|
|
case 'markDone': {
|
|
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
|
const prev = this.state.playback[key];
|
|
if (!prev || prev.phase === 'done') return this.state;
|
|
this.state = bump(this.state, {
|
|
playback: {
|
|
...this.state.playback,
|
|
[key]: {
|
|
...prev,
|
|
phase: 'done',
|
|
baseDist: prev.pathLength,
|
|
rejoinDist: null,
|
|
segmentStartedAtMs: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
return this.state;
|
|
}
|
|
default: {
|
|
const _exhaustive: never = event;
|
|
void _exhaustive;
|
|
return this.state;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type { TokenPathTargetRef };
|