diff --git a/frontend/src/canvas/spring.ts b/frontend/src/canvas/spring.ts new file mode 100644 index 0000000..d64c52e --- /dev/null +++ b/frontend/src/canvas/spring.ts @@ -0,0 +1,149 @@ +/** + * 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; + +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) { + 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 = 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; + } +}