Skip to content

Commit

Permalink
feat(2d): add save and restore methods to nodes (#406)
Browse files Browse the repository at this point in the history
  • Loading branch information
ksassnowski authored Feb 24, 2023
1 parent 8965ab1 commit 870e194
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 8 deletions.
127 changes: 119 additions & 8 deletions packages/2d/src/components/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import {
import type {ComponentChild, ComponentChildren, NodeConstructor} from './types';
import {Promisable} from '@motion-canvas/core/lib/threading';
import {useScene2D} from '../scenes/useScene2D';
import {TimingFunction} from '@motion-canvas/core/lib/tweening';
import {
deepLerp,
easeInOutCubic,
TimingFunction,
tween,
} from '@motion-canvas/core/lib/tweening';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {drawLine} from '../utils';
import type {View2D} from './View2D';
Expand All @@ -43,6 +48,8 @@ import {
isReactive,
} from '@motion-canvas/core/lib/signals';

export type NodeState = NodeProps & Record<string, any>;

export interface NodeProps {
ref?: ReferenceReceiver<any>;
children?: ComponentChildren;
Expand Down Expand Up @@ -392,6 +399,7 @@ export class Node implements Promisable<Node> {
}

protected view2D: View2D;
private stateStack: NodeState[] = [];
private realChildren: Node[] = [];
public readonly parent = createSignal<Node | null>(null);
public readonly properties = getPropertiesOf(this);
Expand Down Expand Up @@ -882,6 +890,7 @@ export class Node implements Promisable<Node> {
* node to be garbage collected.
*/
public dispose() {
this.stateStack = [];
for (const {signal} of this) {
signal.context.dispose();
}
Expand Down Expand Up @@ -927,16 +936,15 @@ export class Node implements Promisable<Node> {
* @param customProps - Properties to override.
*/
public snapshotClone(customProps: NodeProps = {}): this {
const props: NodeProps & Record<string, any> = {...customProps};
const props: NodeProps & Record<string, any> = {
...this.getState(),
...customProps,
};

if (this.children().length > 0) {
props.children ??= this.children().map(child => child.snapshotClone());
}

for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in props) continue;
props[key] = signal();
}

return this.instantiate(props);
}

Expand Down Expand Up @@ -1254,13 +1262,116 @@ export class Node implements Promisable<Node> {
return this;
}

/**
* Return a snapshot of the node's current signal values.
*
* @remarks
* This method will calculate the values of any reactive properties of the
* node at the time the method is called.
*/
public getState(): NodeState {
const state: NodeState = {};
for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in state) continue;
state[key] = signal();
}
return state;
}

/**
* Apply the given state to the node, setting all matching signal values to
* the provided values.
*
* @param state - The state to apply to the node.
*/
public applyState(state: NodeState) {
for (const key in state) {
const signal = this.signalByKey(key);
if (signal) {
signal(state[key]);
}
}
}

/**
* Push a snapshot of the node's current state onto the node's state stack.
*
* @remarks
* This method can be used together with the {@link restore} method to save a
* node's current state and later restore it. It is possible to store more
* than one state by calling `save` method multiple times.
*/
public save(): void {
this.stateStack.push(this.getState());
}

/**
* Restore the node to its last saved state.
*
* @remarks
* This method can be used together with the {@link save} method to restore a
* node to a previously saved state. Restoring a node to a previous state
* removes that state from the state stack.
*
* @example
* ```tsx
* const node = <Circle width={100} height={100} fill={"lightseagreen"} />
*
* view.add(node);
*
* // Save the node's current state
* node.save();
*
* // Modify some of the node's properties
* yield* node.scale(2, 1);
* yield* node.fill('hotpink', 1);
*
* // Restore the node to its saved state over 1 second
* yield* node.restore(1);
* ```
*
* @param duration - The duration of the transition
* @param timing - The timing function to use for the transition
*/
public restore(duration: number, timing: TimingFunction = easeInOutCubic) {
const state = this.stateStack.pop();

if (state === undefined) {
return;
}

const currentState = this.getState();
for (const key in state) {
// Filter out any properties that haven't changed between the current and
// previous states so we don't perform unnecessary tweens.
if (currentState[key] === state[key]) {
delete state[key];
}
}

return tween(duration, value => {
const t = timing(value);

const nextState = Object.keys(state).reduce((newState, key) => {
newState[key] = deepLerp(currentState[key], state[key], t);
return newState;
}, {} as NodeState);

this.applyState(nextState);
});
}

public *[Symbol.iterator]() {
for (const key in this.properties) {
const meta = this.properties[key];
const signal = (<Record<string, SimpleSignal<any>>>(<unknown>this))[key];
const signal = this.signalByKey(key);
yield {meta, signal, key};
}
}

private signalByKey(key: string): SimpleSignal<any> {
return (<Record<string, SimpleSignal<any>>>(<unknown>this))[key];
}
}

/*@__PURE__*/
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/tweening/interpolationFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,17 @@ describe('deepLerp', () => {
expect(deepLerp(...args)).toEqual(Vector2.lerp(...args));
expect(spy).toHaveBeenCalledTimes(2);
});

test('returns the from boolean until a value of 0.5', () => {
expect(deepLerp(true, false, 0)).toBe(true);
expect(deepLerp(true, false, 0.25)).toBe(true);
expect(deepLerp(true, false, 0.499999)).toBe(true);
});

test('returns the to boolean after a value of 0.5 or greater', () => {
expect(deepLerp(true, false, 0.5)).toBe(false);
expect(deepLerp(true, false, 0.75)).toBe(false);
expect(deepLerp(true, false, 0.99999)).toBe(false);
expect(deepLerp(true, false, 1)).toBe(false);
});
});
4 changes: 4 additions & 0 deletions packages/core/src/tweening/interpolationFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export function deepLerp(
return textLerp(from, to, value);
}

if (typeof from === 'boolean' && typeof to === 'boolean') {
return value < 0.5 ? from : to;
}

if ('lerp' in from) {
return from.lerp(to, value);
}
Expand Down
36 changes: 36 additions & 0 deletions packages/docs/docs/getting-started/tweening.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ slug: /tweening
import AnimationPlayer from '@site/src/components/AnimationPlayer';
import CodeBlock from '@theme/CodeBlock';
import linearSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-linear';
import saveRestoreSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-save-restore';
import springSource from '!!raw-loader!@motion-canvas/examples/src/scenes/tweening-spring';

# Tweening
Expand Down Expand Up @@ -260,3 +261,38 @@ function finishes **only** when the spring settles. By adjusting the tolerance
we can make the animation finish faster, depending on our needs. In our example
we animate the position so a tolerance of `1` means that the spring needs to be
at most `1` pixel away from the `to` value.

## Saving and restoring states

All nodes provide a [`save`](/api/2d/components/Node#save) method which allows
us to save a snapshot of the node's current state. We can then use the
[`restore`](/api/2d/components/Node#restore) method at a later point in our
animation to restore the node to the previously saved state.

```ts
// highlight-next-line
circle().save();
yield * circle().position(new Vector2(300, -200), 2);
// highlight-next-line
yield * circle().restore(1);
```

It is also possible to provide a custom [timing function](/api/core/tweening) to
the [`restore`](/api/2d/components/Node#restore) method.

```ts
yield * circle().restore(1, linear);
```

Node states get stored on a stack. This makes it possible to save more than one
state by invoking the [`save`](/api/2d/components/Node#save) method multiple
times. When calling [`restore`](/api/2d/components/Node#restore), the node will
be restored to the most recently saved state by popping the top entry in the
state stack. If there is no saved state, this method does nothing.

The example below shows a more complete example of how we can store and restore
multiple states across an animation.

<CodeBlock language="tsx">{saveRestoreSource}</CodeBlock>

<AnimationPlayer small name="tweening-save-restore" />
4 changes: 4 additions & 0 deletions packages/examples/src/scenes/tweening-save-restore.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"version": 1,
"seed": 1271865379
}
31 changes: 31 additions & 0 deletions packages/examples/src/scenes/tweening-save-restore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {makeScene2D} from '@motion-canvas/2d';
import {Circle} from '@motion-canvas/2d/lib/components';
import {createRef} from '@motion-canvas/core/lib/utils';
import {all} from '@motion-canvas/core/lib/flow';

export default makeScene2D(function* (view) {
const circle = createRef<Circle>();

view.add(
<Circle
// highlight-start
ref={circle}
size={150}
position={[-300, -300]}
fill={'#e13238'}
/>,
);

circle().save();
yield* all(circle().position.x(0, 1), circle().scale(1.5, 1));

circle().save();
yield* all(circle().position.y(0, 1), circle().scale(0.5, 1));

circle().save();
yield* all(circle().position.x(300, 1), circle().scale(1, 1));

yield* circle().restore(1);
yield* circle().restore(1);
yield* circle().restore(1);
});
3 changes: 3 additions & 0 deletions packages/examples/src/tweening-save-restore.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"version": 0
}
7 changes: 7 additions & 0 deletions packages/examples/src/tweening-save-restore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {makeProject} from '@motion-canvas/core';

import scene from './scenes/tweening-save-restore?scene';

export default makeProject({
scenes: [scene],
});
1 change: 1 addition & 0 deletions packages/examples/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default defineConfig({
'./src/logging.ts',
'./src/transitions.ts',
'./src/tweening-spring.ts',
'./src/tweening-save-restore.ts',
],
}),
],
Expand Down

0 comments on commit 870e194

Please sign in to comment.