summaryrefslogtreecommitdiff
path: root/apps/web/src/animation/interpolation.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/animation/interpolation.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/animation/interpolation.ts')
-rw-r--r--apps/web/src/animation/interpolation.ts375
1 files changed, 375 insertions, 0 deletions
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
new file mode 100644
index 00000000..277bfbb7
--- /dev/null
+++ b/apps/web/src/animation/interpolation.ts
@@ -0,0 +1,375 @@
+import type {
+ AnimationChannel,
+ AnimationInterpolation,
+ AnimationValue,
+ DiscreteAnimationChannel,
+ DiscreteValue,
+ ScalarAnimationChannel,
+ ScalarAnimationKey,
+ ScalarSegmentType,
+} from "@/animation/types";
+import { clamp } from "@/utils/math";
+import {
+ getBezierPoint,
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+ solveBezierProgressForTime,
+} from "./bezier";
+
+function byTimeAscending({
+ leftTime,
+ rightTime,
+}: {
+ leftTime: number;
+ rightTime: number;
+}): number {
+ return leftTime - rightTime;
+}
+
+function isWithinTimePair({
+ time,
+ leftTime,
+ rightTime,
+}: {
+ time: number;
+ leftTime: number;
+ rightTime: number;
+}): boolean {
+ return time >= leftTime && time <= rightTime;
+}
+
+function lerpNumber({
+ leftValue,
+ rightValue,
+ progress,
+}: {
+ leftValue: number;
+ rightValue: number;
+ progress: number;
+}): number {
+ return leftValue + (rightValue - leftValue) * progress;
+}
+
+function normalizeRightHandle({
+ handle,
+ leftKey,
+ rightKey,
+}: {
+ handle: ScalarAnimationKey["rightHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
+ }
+
+ const span = Math.max(1, rightKey.time - leftKey.time);
+ return {
+ dt: Math.min(span, Math.max(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeLeftHandle({
+ handle,
+ leftKey,
+ rightKey,
+}: {
+ handle: ScalarAnimationKey["leftHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
+ }
+
+ const span = Math.max(1, rightKey.time - leftKey.time);
+ return {
+ dt: Math.max(-span, Math.min(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeScalarKey({
+ key,
+}: {
+ key: ScalarAnimationKey;
+}): ScalarAnimationKey {
+ return {
+ ...key,
+ tangentMode: key.tangentMode ?? "flat",
+ segmentToNext: key.segmentToNext ?? "linear",
+ };
+}
+
+function normalizeScalarChannel({
+ channel,
+}: {
+ channel: ScalarAnimationChannel;
+}): ScalarAnimationChannel {
+ const sortedKeys = [...channel.keys]
+ .map((key) => normalizeScalarKey({ key }))
+ .sort((leftKey, rightKey) =>
+ byTimeAscending({
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ }),
+ );
+ const nextKeys = sortedKeys.map((key, index) => {
+ const previousKey = sortedKeys[index - 1];
+ const nextKey = sortedKeys[index + 1];
+ return {
+ ...key,
+ leftHandle:
+ previousKey != null
+ ? normalizeLeftHandle({
+ handle: key.leftHandle,
+ leftKey: previousKey,
+ rightKey: key,
+ })
+ : undefined,
+ rightHandle:
+ nextKey != null
+ ? normalizeRightHandle({
+ handle: key.rightHandle,
+ leftKey: key,
+ rightKey: nextKey,
+ })
+ : undefined,
+ };
+ });
+
+ return {
+ ...channel,
+ keys: nextKeys,
+ };
+}
+
+export function normalizeChannel<TChannel extends AnimationChannel>({
+ channel,
+}: {
+ channel: TChannel;
+}): TChannel {
+ if (channel.kind === "scalar") {
+ return normalizeScalarChannel({
+ channel,
+ }) as TChannel;
+ }
+
+ return {
+ ...channel,
+ keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
+ byTimeAscending({
+ leftTime: leftKeyframe.time,
+ rightTime: rightKeyframe.time,
+ }),
+ ),
+ } as TChannel;
+}
+
+function extrapolateScalarEdge({
+ mode,
+ edgeKey,
+ neighborKey,
+ time,
+}: {
+ mode: "hold" | "linear";
+ edgeKey: ScalarAnimationKey;
+ neighborKey: ScalarAnimationKey | undefined;
+ time: number;
+}) {
+ if (mode === "hold" || !neighborKey) {
+ return edgeKey.value;
+ }
+
+ const span = neighborKey.time - edgeKey.time;
+ if (span === 0) {
+ return edgeKey.value;
+ }
+
+ return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
+}
+
+export function getScalarSegmentInterpolation({
+ segment,
+}: {
+ segment: ScalarSegmentType;
+}): AnimationInterpolation {
+ if (segment === "step") {
+ return "hold";
+ }
+
+ return segment === "bezier" ? "bezier" : "linear";
+}
+
+export function getScalarChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ time: number;
+ fallbackValue: number;
+}): number {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ const normalizedChannel = normalizeChannel({
+ channel,
+ });
+ const firstKey = normalizedChannel.keys[0];
+ const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
+ if (!firstKey || !lastKey) {
+ return fallbackValue;
+ }
+
+ if (time <= firstKey.time) {
+ if (time < firstKey.time) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.before ?? "hold",
+ edgeKey: firstKey,
+ neighborKey: normalizedChannel.keys[1],
+ time,
+ });
+ }
+
+ return firstKey.value;
+ }
+
+ if (time >= lastKey.time) {
+ if (time > lastKey.time) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.after ?? "hold",
+ edgeKey: lastKey,
+ neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
+ time,
+ });
+ }
+
+ return lastKey.value;
+ }
+
+ for (
+ let keyIndex = 0;
+ keyIndex < normalizedChannel.keys.length - 1;
+ keyIndex++
+ ) {
+ const leftKey = normalizedChannel.keys[keyIndex];
+ const rightKey = normalizedChannel.keys[keyIndex + 1];
+ if (time === rightKey.time) {
+ return rightKey.value;
+ }
+
+ if (
+ !isWithinTimePair({
+ time,
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ })
+ ) {
+ continue;
+ }
+
+ if (leftKey.segmentToNext === "step") {
+ return leftKey.value;
+ }
+
+ const span = rightKey.time - leftKey.time;
+ if (span === 0) {
+ return rightKey.value;
+ }
+
+ const progress = clamp({
+ value: (time - leftKey.time) / span,
+ min: 0,
+ max: 1,
+ });
+ if (leftKey.segmentToNext === "linear") {
+ return lerpNumber({
+ leftValue: leftKey.value,
+ rightValue: rightKey.value,
+ progress,
+ });
+ }
+
+ const curveProgress = solveBezierProgressForTime({
+ time,
+ leftKey,
+ rightKey,
+ });
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+ return getBezierPoint({
+ progress: curveProgress,
+ p0: leftKey.value,
+ p1: leftKey.value + rightHandle.dv,
+ p2: rightKey.value + leftHandle.dv,
+ p3: rightKey.value,
+ });
+ }
+
+ return lastKey.value;
+}
+
+export function getDiscreteChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: DiscreteValue;
+}): DiscreteValue {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ const normalizedChannel = normalizeChannel({
+ channel,
+ });
+ let currentValue = fallbackValue;
+ for (const key of normalizedChannel.keys) {
+ if (time < key.time) {
+ break;
+ }
+ currentValue = key.value;
+ }
+ return currentValue;
+}
+
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ fallbackValue: AnimationValue;
+}): AnimationValue {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ if (channel.kind === "scalar") {
+ return typeof fallbackValue === "number"
+ ? getScalarChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ })
+ : fallbackValue;
+ }
+
+ if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
+ return fallbackValue;
+ }
+
+ return getDiscreteChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ });
+}