Files
refboard-ayon/frontend/src/canvas/spring.ts
T
Hiren Kangad d7766a027a Add spring physics engine for GPU-driven animations
Damped harmonic oscillator with configurable stiffness, damping, mass,
and precision. Includes four presets (drop, gentle, snappy, bounce) and
a SpringManager to batch-tick and auto-remove settled springs.
2026-03-09 23:04:48 +05:30

150 lines
4.0 KiB
TypeScript

/**
* Spring Physics Engine — damped harmonic oscillator for GPU-driven animations.
*
* Used by SceneManager for drop animations, drag lift/release, delete fade-out,
* group/ungroup, and inbox zone slide-in.
*
* Physics: F = -stiffness * displacement - damping * velocity
* a = F / mass
*/
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export interface SpringConfig {
stiffness: number;
damping: number;
mass: number;
precision: number;
}
const DEFAULT_CONFIG: SpringConfig = {
stiffness: 180,
damping: 12,
mass: 1,
precision: 0.01,
};
export const PRESETS = {
drop: { stiffness: 180, damping: 12, mass: 1, precision: 0.01 },
gentle: { stiffness: 120, damping: 14, mass: 1, precision: 0.01 },
snappy: { stiffness: 300, damping: 20, mass: 1, precision: 0.01 },
bounce: { stiffness: 200, damping: 8, mass: 1, precision: 0.01 },
} as const satisfies Record<string, SpringConfig>;
export type PresetName = keyof typeof PRESETS;
// ---------------------------------------------------------------------------
// Spring
// ---------------------------------------------------------------------------
export class Spring {
private _value: number;
private _target: number;
private _velocity: number = 0;
private _done: boolean = false;
private readonly _config: SpringConfig;
/** Called every tick with the current value. */
onUpdate: ((value: number) => void) | null = null;
/** Called once when the spring settles (snaps to target). */
onComplete: (() => void) | null = null;
constructor(initial: number, target: number, config?: Partial<SpringConfig>) {
this._value = initial;
this._target = target;
this._config = { ...DEFAULT_CONFIG, ...config };
}
// -- Public accessors -----------------------------------------------------
get value(): number {
return this._value;
}
get target(): number {
return this._target;
}
set target(t: number) {
this._target = t;
this._done = false;
}
get velocity(): number {
return this._velocity;
}
get done(): boolean {
return this._done;
}
// -- Simulation -----------------------------------------------------------
/**
* Advance the simulation by `dt` seconds.
*
* Uses semi-implicit Euler integration which is cheap, stable enough for
* UI springs, and deterministic for a given dt.
*/
tick(dt: number): void {
if (this._done) return;
const { stiffness, damping, mass, precision } = this._config;
const displacement = this._value - this._target;
// F = -k * x - c * v
const force = -stiffness * displacement - damping * this._velocity;
const acceleration = force / mass;
// Semi-implicit Euler: update velocity first, then position.
this._velocity += acceleration * dt;
this._value += this._velocity * dt;
// Settle check
if (Math.abs(this._velocity) < precision && Math.abs(this._value - this._target) < precision) {
this._value = this._target;
this._velocity = 0;
this._done = true;
this.onUpdate?.(this._value);
this.onComplete?.();
return;
}
this.onUpdate?.(this._value);
}
}
// ---------------------------------------------------------------------------
// SpringManager
// ---------------------------------------------------------------------------
export class SpringManager {
private _springs: Set<Spring> = new Set();
/** Register a spring and return it for chaining. */
add(spring: Spring): Spring {
this._springs.add(spring);
return spring;
}
/** Advance all springs by `dt` seconds, removing settled ones. */
tick(dt: number): void {
for (const spring of this._springs) {
spring.tick(dt);
if (spring.done) {
this._springs.delete(spring);
}
}
}
/** Number of currently active (unsettled) springs. */
get active(): number {
return this._springs.size;
}
}