Repository: motion-canvas/motion-canvas
File: packages/core/src/tweening/spring.ts (TypeScript)

Code before:
    mass: 0.05,
    stiffness: 10,
    damping: 0.5,
  };

  if (spring.mass <= 0) {
    useLogger().error(new Error('Spring mass must be greater 0.'));
    return;
  }
  if (spring.stiffness < 0) {
    useLogger().error(
      new Error('Spring stiffness must be greater or equal to 0.'),
    );
    return;
  }
  if (spring.damping < 0) {
    useLogger().error(
      new Error('Spring damping must be greater or equal to 0.'),
    );
    return;
  }

  const project = useProject();
  const thread = useThread();

  const startTime = thread.time();

  // figure out the step length for each frame
  const timeStep = 1 / project.framerate;

  settleTolerance = settleTolerance ?? 0.001;
  let position = from;
  let velocity = spring.initalVelocity ?? 0;
  let settled = false;

  onProgress(from, 0);
  while (!settled) {
    const positionDelta = position - to;

    // using hooks law: F=-kx; with k being the spring constant and x the offset to the settling position
    const force = -spring.stiffness * positionDelta - spring.damping * velocity;

    //updating the velocity
    velocity += (force / spring.mass) * timeStep;

    // apply velocity to the position
    position += velocity * timeStep;

    // the simulation would run forever but is often barely noticable after some seconds
    // we fix this by checking if the delta delta between $to and $position is smaller than the given $settleTolerance
    // but we also need to check if the velocity is below the tolerance too

Review comment (bug):
You'll need to account for the time offset of the current thread.
Threads can be offset relative to the global time (`project.time`) by values shorter than the duration of one frame. This allows you to do things like `waitFor(0.005)` that work independently of the framerate.
The current implementation will produce incorrect results when dispatching springs in quick succession (the line is not smooth):
Now | Should be
--- | ---
![image](https://user-images.githubusercontent.com/64662184/219302223-240cb4a1-b6b3-4ecc-b180-8b0ceab57688.png) | ![image](https://user-images.githubusercontent.com/64662184/219305977-747fa1af-4113-4e55-b06c-9a1b6da5df56.png)

Repro:
```tsx
import {makeScene2D} from '@motion-canvas/2d';
import {Circle, Node} from '@motion-canvas/2d/lib/components';
import {sequence, waitFor} from '@motion-canvas/core/lib/flow';
import {makeRef, range} from '@motion-canvas/core/lib/utils';
import {spring} from '@motion-canvas/core/lib/tweening';

export default makeScene2D(function* (view) {
  const circles: Circle[] = [];

  view.add(
    <Node>
      {range(9).map(i => (
        <Circle
          ref={makeRef(circles, i)}
          x={(i - 4) * 100}
          y={-400}
          size={50}
          fill={'white'}
        />
      ))}
    </Node>,
  );

  yield* sequence(
    0.03,
    ...circles.map(circle =>
      spring(null, -400, 400, null, value => {
        circle.position.y(value);
      }),
    ),
  );

  yield* waitFor(5);
});
```

One way to do that would be by calculating the time step manually:
```ts
let lastUpdateTime = thread.time();
while (!settled) {
  // this timeStep will account for the difference between the global time and this thread's time.
  const timeStep = project.time - lastUpdateTime; 
  if (timeStep <= 0) {
    yield;
    continue;
  }
  lastUpdateTime = project.time;
  // rest of the calculation
}
```


Code after:
): ThreadGenerator;

export function spring(
  spring: Spring | null,
  from: number,
  to: number,
  onProgress: ProgressFunction,
  onEnd?: ProgressFunction,
): ThreadGenerator;

export function* spring(
  spring: Spring | null,
  from: number,
  to: number,
  settleToleranceOrOnProgress: number | ProgressFunction,
  onProgressOrOnEnd?: ProgressFunction,
  onEnd?: ProgressFunction,
): ThreadGenerator {
  const settleTolerance =
    typeof settleToleranceOrOnProgress === 'number'
      ? settleToleranceOrOnProgress
      : 0.001;

  onEnd =
    typeof settleToleranceOrOnProgress === 'number' ? onEnd : onProgressOrOnEnd;

  const onProgress: ProgressFunction = (value: number, time: number) => {
    if (typeof settleToleranceOrOnProgress === 'function') {
      settleToleranceOrOnProgress(value, time);
    } else if (typeof onProgressOrOnEnd === 'function') {
      onProgressOrOnEnd(value, time);
    }
  };

  spring = spring ?? {
    mass: 0.05,
    stiffness: 10,
    damping: 0.5,
  };

  if (spring.mass <= 0) {
    useLogger().error(new Error('Spring mass must be greater than 0.'));
    return;
  }
  if (spring.stiffness < 0) {
    useLogger().error(
      new Error('Spring stiffness must be greater or equal to 0.'),
    );
    return;
  }
  if (spring.damping < 0) {