From 665e2241228980aae974d7a031b92cd99cfeedf6 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:37:46 +0300 Subject: [PATCH] feat(playground): input/output pipelines + curve catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-TS ports of the legacy input + output pipelines: - src/input/pipeline.ts: deadzone → circular clamp → zoom → centered power curve → EMA smoothing → momentum-as-zoom. Exposed as a pure function processInput(raw, cfg, state, dt) so the input-store can hold the state. Math is intentionally bit-equivalent to the legacy js/ui/input-pipeline.js implementation. - src/output/pipeline.ts: global power curve → EMA smoothing → slew-rate limit → freeze gate (global + per-output mask). - src/output/curves.ts: named curve catalog (linear/exp/log/square/ sqrt/sigmoid/cubic/centered_power) — the TS half of the contract defined in nisps/core/math.hpp (stream 1). Golden-vector tests in stream 11 will keep them in lockstep. Stream 8 of the rewrite (meml-911). --- playground/src/input/pipeline.ts | 307 ++++++++++++++++++++++++++++++ playground/src/output/curves.ts | 128 +++++++++++++ playground/src/output/pipeline.ts | 153 +++++++++++++++ 3 files changed, 588 insertions(+) create mode 100644 playground/src/input/pipeline.ts create mode 100644 playground/src/output/curves.ts create mode 100644 playground/src/output/pipeline.ts diff --git a/playground/src/input/pipeline.ts b/playground/src/input/pipeline.ts new file mode 100644 index 0000000..4cf94e1 --- /dev/null +++ b/playground/src/input/pipeline.ts @@ -0,0 +1,307 @@ +/** + * Input pipeline — pure TS port of legacy `js/ui/input-pipeline.js`. + * + * Stages (in order), each input/output in [0,1]: + * 0. Invert (per-axis flip) + * 1. Deadzone (suppress jitter near center, remap live zone to [0,1]) + * 2. Circular clamp (constrain to unit disk centered at 0.5,0.5) + * 3. Zoom (narrow window around anchor, modulated by momentum) + * 4. Centered power curve (per-axis exponent) + * 5. EMA smoothing (frame-rate-independent) + * 6. Momentum-as-zoom update (consumed next frame) + * + * `processInput` is a pure function over (raw, cfg, prev): returns the new + * processed coordinate plus the next-frame state. Consumer (input-store) + * holds the state and calls this each frame. + * + * Math is intentionally bit-for-bit equivalent to the legacy implementation. + */ + +import { clamp, curveCenteredPower } from '../output/curves'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const ZOOM_MIN = 0.01; +export const ZOOM_MAX = 1.0; +export const FREEZE_THRESHOLD = ZOOM_MIN; + +export const DEADZONE_MAX = 0.4; +export const INPUT_CURVE_MIN = 0.2; +export const INPUT_CURVE_MAX = 5.0; +export const SMOOTHING_MAX = 0.95; +export const VELOCITY_WINDOW_DEFAULT = 150; // ms + +const REFERENCE_DT = 1 / 60; + +export type MomentumZoomMode = 'off' | 'gentle' | 'strong'; +export type AnchorMode = 'auto' | 'sticky' | 'center'; + +interface MomentumPreset { + factor: number; + minZoomMul: number; + maxZoomMul: number; +} + +const MOMENTUM_PRESETS: Record = { + off: null, + gentle: { factor: 0.6, minZoomMul: 0.3, maxZoomMul: 1.0 }, + strong: { factor: 1.5, minZoomMul: 0.15, maxZoomMul: 1.0 }, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InputConfig { + /** Global zoom level [0.01, 1.0] */ + zoom: number; + /** Optional per-axis zoom; overrides global when not null */ + zoomX: number | null; + zoomY: number | null; + /** Anchor point [0,1]^2 (used in sticky/auto modes) */ + anchorX: number; + anchorY: number; + anchorMode: AnchorMode; + /** Deadzone fraction of half-travel [0, 0.4] */ + deadzone: number; + /** Centered power curve exponent [0.2, 5.0] (1.0 = linear) */ + inputCurve: number; + inputCurveX: number | null; + inputCurveY: number | null; + /** EMA smoothing factor [0, 0.95] */ + smoothing: number; + /** Momentum-as-zoom preset */ + momentumZoom: MomentumZoomMode; + velocityWindow: number; + /** Per-axis inversion */ + invertX: boolean; + invertY: boolean; +} + +export interface InputState { + /** Last smoothed output x; seed at 0.5 */ + smoothedX: number; + smoothedY: number; + /** Velocity history ring used for momentum-zoom */ + velocityHistory: ReadonlyArray<{ x: number; y: number; t: number }>; + /** Most recent momentum-zoom multiplier (1 = no scale) */ + momentumZoomMultiplier: number; + /** Whether last process call returned frozen=true */ + frozen: boolean; +} + +export interface ProcessResult { + x: number; + y: number; + frozen: boolean; + /** Next frame's state — consumer should keep this and pass it back. */ + state: InputState; +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +export function defaultInputConfig(): InputConfig { + return { + zoom: 1.0, + zoomX: null, + zoomY: null, + anchorX: 0.5, + anchorY: 0.5, + anchorMode: 'center', + deadzone: 0, + inputCurve: 1.0, + inputCurveX: null, + inputCurveY: null, + smoothing: 0, + momentumZoom: 'off', + velocityWindow: VELOCITY_WINDOW_DEFAULT, + invertX: false, + invertY: false, + }; +} + +export function defaultInputState(): InputState { + return { + smoothedX: 0.5, + smoothedY: 0.5, + velocityHistory: [], + momentumZoomMultiplier: 1, + frozen: false, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function applyDeadzone(input: number, deadzone: number): number { + if (deadzone <= 0) return input; + const offset = input - 0.5; + const absOff = Math.abs(offset); + const halfDz = deadzone * 0.5; + if (absOff <= halfDz) return 0.5; + const sign = offset < 0 ? -1 : 1; + const remapped = ((absOff - halfDz) / (0.5 - halfDz)) * 0.5; + return 0.5 + sign * remapped; +} + +function applyZoom(input: number, anchor: number, zoomLevel: number): number { + return clamp(anchor + (input - 0.5) * zoomLevel, 0, 1); +} + +function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number { + if (smoothing <= 0) return raw; + const effectiveDt = dt > 0 ? dt : REFERENCE_DT; + const alpha = 1 - smoothing; + const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT); + return prev + alphaEff * (raw - prev); +} + +function updateMomentumZoomMultiplier( + cfg: InputConfig, + state: InputState, + rawX: number, + rawY: number, + dt: number, +): { multiplier: number; history: InputState['velocityHistory'] } { + const preset = MOMENTUM_PRESETS[cfg.momentumZoom]; + if (!preset) { + return { multiplier: 1, history: [] }; + } + const now = performance.now(); + const window = cfg.velocityWindow; + // Append, drop entries older than `window` ms + const trimmed = state.velocityHistory.filter((p) => now - p.t <= window); + const newHist = [...trimmed, { x: rawX, y: rawY, t: now }]; + if (newHist.length < 2) { + return { multiplier: 1, history: newHist }; + } + const a = newHist[0]!; + const b = newHist[newHist.length - 1]!; + const dtMs = b.t - a.t; + if (dtMs <= 0) return { multiplier: state.momentumZoomMultiplier, history: newHist }; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const speed = dist / (dtMs / 1000); // [0,1]-space units per second + const normSpeed = clamp(speed * preset.factor, 0, 1); + // Higher speed → smaller multiplier (zoom out faster movements) + const target = preset.maxZoomMul - (preset.maxZoomMul - preset.minZoomMul) * normSpeed; + // Smooth toward target so the zoom doesn't jitter + const smoothCoeff = clamp(dt * 6, 0, 1); + const next = state.momentumZoomMultiplier + smoothCoeff * (target - state.momentumZoomMultiplier); + return { multiplier: next, history: newHist }; +} + +function resolveAnchorX(cfg: InputConfig, state: InputState): number { + if (cfg.anchorMode === 'center') return 0.5; + if (cfg.anchorMode === 'sticky') return cfg.anchorX; + // auto: use stored anchor (input-store updates it on zoom changes) + return cfg.anchorX; +} + +function resolveAnchorY(cfg: InputConfig, state: InputState): number { + if (cfg.anchorMode === 'center') return 0.5; + if (cfg.anchorMode === 'sticky') return cfg.anchorY; + return cfg.anchorY; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Process raw 2D input through the pipeline. + * + * @param raw raw input [x, y] in [0,1] + * @param cfg pipeline configuration + * @param state prior state (use {@link defaultInputState} on first call) + * @param dt seconds since last call (default 1/60) + */ +export function processInput( + raw: readonly [number, number], + cfg: InputConfig, + state: InputState, + dt: number = REFERENCE_DT, +): ProcessResult { + const safeDt = Math.max(0, dt); + const baseZoomX = cfg.zoomX ?? cfg.zoom; + const baseZoomY = cfg.zoomY ?? cfg.zoom; + + const frozenX = baseZoomX <= FREEZE_THRESHOLD; + const frozenY = baseZoomY <= FREEZE_THRESHOLD; + const fullyFrozen = frozenX && frozenY; + + if (fullyFrozen) { + return { + x: state.smoothedX, + y: state.smoothedY, + frozen: true, + state: { ...state, frozen: true }, + }; + } + + let [rawX, rawY] = raw; + + // 0. Invert + let x = cfg.invertX ? 1 - rawX : rawX; + let y = cfg.invertY ? 1 - rawY : rawY; + + // 1. Deadzone + x = applyDeadzone(x, cfg.deadzone); + y = applyDeadzone(y, cfg.deadzone); + + // 2. Circular clamp to unit disk centered at (0.5, 0.5) + { + const cx = x - 0.5; + const cy = y - 0.5; + const dist = Math.sqrt(cx * cx + cy * cy); + if (dist > 0.5 && dist > 1e-12) { + const scale = 0.5 / dist; + x = 0.5 + cx * scale; + y = 0.5 + cy * scale; + } + } + + // 3. Zoom around anchor (with momentum modulation) + const anchorX = resolveAnchorX(cfg, state); + const anchorY = resolveAnchorY(cfg, state); + const effZoomX = frozenX + ? FREEZE_THRESHOLD + : clamp(baseZoomX * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX); + const effZoomY = frozenY + ? FREEZE_THRESHOLD + : clamp(baseZoomY * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX); + x = frozenX ? state.smoothedX : applyZoom(x, anchorX, effZoomX); + y = frozenY ? state.smoothedY : applyZoom(y, anchorY, effZoomY); + + // 4. Centered power curve + const curveX = cfg.inputCurveX ?? cfg.inputCurve; + const curveY = cfg.inputCurveY ?? cfg.inputCurve; + if (!frozenX) x = curveCenteredPower(x, curveX); + if (!frozenY) y = curveCenteredPower(y, curveY); + + // 5. EMA smoothing + const smoothedX = frozenX ? state.smoothedX : emaSmooth(state.smoothedX, x, cfg.smoothing, safeDt); + const smoothedY = frozenY ? state.smoothedY : emaSmooth(state.smoothedY, y, cfg.smoothing, safeDt); + + // 6. Update momentum-zoom for next frame + const { multiplier, history } = updateMomentumZoomMultiplier(cfg, state, rawX, rawY, safeDt); + + return { + x: smoothedX, + y: smoothedY, + frozen: false, + state: { + smoothedX, + smoothedY, + velocityHistory: history, + momentumZoomMultiplier: multiplier, + frozen: false, + }, + }; +} diff --git a/playground/src/output/curves.ts b/playground/src/output/curves.ts new file mode 100644 index 0000000..a58bbcb --- /dev/null +++ b/playground/src/output/curves.ts @@ -0,0 +1,128 @@ +/** + * Curve catalog — TypeScript mirror of the named curves from + * nisps/core/math.hpp (forthcoming, stream 1). All inputs and outputs are in + * [0, 1] unless noted otherwise. + * + * IMPORTANT: This file MUST stay in lockstep with the C++ side. The + * authoritative reference is `nisps/core/math.hpp`. Golden-vector tests + * (stream 11) compare WASM-computed vs TS-computed outputs and fail on + * any drift. + * + * Architecture §5.3: + * linear, exp, log, square, sqrt, sigmoid, cubic, centered_power + * + * The "centered_power" variant comes from the legacy input/output pipelines + * and shapes around 0.5 instead of 0.0. Kept as a named curve because both + * input and output pipelines use it. + */ + +export type CurveName = + | 'linear' + | 'exp' + | 'log' + | 'square' + | 'sqrt' + | 'sigmoid' + | 'cubic' + | 'centered_power'; + +/** Hard clamp to [0, 1]. */ +export function clamp01(v: number): number { + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} + +/** Generic clamp. */ +export function clamp(v: number, lo: number, hi: number): number { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +/** Linear: identity. */ +export function curveLinear(x: number): number { + return clamp01(x); +} + +/** Exponential: e^(k*x) - 1, normalized to [0,1] over [0,1] input. */ +export function curveExp(x: number, k: number = 4.0): number { + if (x <= 0) return 0; + if (x >= 1) return 1; + const denom = Math.exp(k) - 1.0; + if (denom === 0) return x; + return (Math.exp(k * x) - 1.0) / denom; +} + +/** Inverse of curveExp. */ +export function curveLog(x: number, k: number = 4.0): number { + if (x <= 0) return 0; + if (x >= 1) return 1; + const denom = Math.exp(k) - 1.0; + if (denom === 0) return x; + return Math.log(1 + x * denom) / k; +} + +/** Square: x^2. */ +export function curveSquare(x: number): number { + const v = clamp01(x); + return v * v; +} + +/** Square-root. */ +export function curveSqrt(x: number): number { + return Math.sqrt(clamp01(x)); +} + +/** Logistic sigmoid mapped onto [0,1] domain (centered at x=0.5). */ +export function curveSigmoid(x: number, slope: number = 8.0): number { + // Sigmoid centered at 0.5 with given slope. Output is in (0, 1). + // Normalize so endpoints map exactly to 0 and 1. + const t = (x - 0.5) * slope; + const s = 1 / (1 + Math.exp(-t)); + // Anchor: when x=0, t=-slope/2; when x=1, t=+slope/2 + const sLo = 1 / (1 + Math.exp(slope / 2)); + const sHi = 1 / (1 + Math.exp(-slope / 2)); + return (s - sLo) / (sHi - sLo); +} + +/** Cubic ease-in-out. */ +export function curveCubic(x: number): number { + const v = clamp01(x); + // Smoothstep cubic: 3v^2 - 2v^3 + return v * v * (3 - 2 * v); +} + +/** + * Centered power curve. Pivots around 0.5. + * + * exponent < 1 → push toward extremes + * exponent = 1 → identity + * exponent > 1 → pull toward center + */ +export function curveCenteredPower(x: number, exponent: number): number { + if (exponent === 1) return clamp01(x); + const offset = x - 0.5; + const sign = offset < 0 ? -1 : 1; + // Range [-0.5, 0.5] -> [-1, 1] for the power op, then halve back. + const shaped = (sign * Math.pow(Math.abs(offset) * 2, exponent)) / 2; + return clamp01(shaped + 0.5); +} + +/** Apply by name. `param` interpretation depends on the curve. */ +export function applyCurve(name: CurveName, x: number, param?: number): number { + switch (name) { + case 'linear': return curveLinear(x); + case 'exp': return curveExp(x, param ?? 4.0); + case 'log': return curveLog(x, param ?? 4.0); + case 'square': return curveSquare(x); + case 'sqrt': return curveSqrt(x); + case 'sigmoid': return curveSigmoid(x, param ?? 8.0); + case 'cubic': return curveCubic(x); + case 'centered_power': return curveCenteredPower(x, param ?? 1.0); + } +} + +export const CURVE_NAMES: ReadonlyArray = [ + 'linear', 'exp', 'log', 'square', 'sqrt', 'sigmoid', 'cubic', 'centered_power', +]; diff --git a/playground/src/output/pipeline.ts b/playground/src/output/pipeline.ts new file mode 100644 index 0000000..bc0e2b6 --- /dev/null +++ b/playground/src/output/pipeline.ts @@ -0,0 +1,153 @@ +/** + * Output pipeline — pure TS port of legacy `js/ui/output-pipeline.js`. + * + * Stages (in order) for each output: + * 1. Global power curve (raw^exponent, exponent in [0.2, 5.0]) + * 2. Per-output EMA smoothing (frame-rate-independent) + * 3. Slew-rate limiting (max change per second per output) + * 4. Freeze gate (global) and per-output freeze mask + * + * `processOutput` is a pure function: takes the raw output vector, the prior + * processed vector (or null on first call), and config; returns a new + * Float32Array. Consumer (output-store) holds prev between frames. + * + * NOTE: Reuses an internal scratch buffer ONLY when a prev buffer of the + * exact same length is supplied AND `cfg.reuseBuffer === true`. Otherwise it + * allocates a fresh Float32Array (safer for cross-component sharing). + */ + +import { clamp, clamp01 } from './curves'; + +export const GLOBAL_CURVE_MIN = 0.2; +export const GLOBAL_CURVE_MAX = 5.0; +export const SMOOTHING_MAX = 0.95; +export const SLEW_RATE_MIN = 0.005; + +const REFERENCE_DT = 1 / 60; + +export interface OutputConfig { + /** Power curve exponent applied to ALL outputs. 1 = linear. */ + globalCurve: number; + /** EMA smoothing factor [0, 0.95]. */ + smoothing: number; + /** Max change per second per output. Infinity = unlimited. */ + slewRate: number; + /** Global freeze gate. */ + freezeOutput: boolean; + /** Per-output freeze mask (1 = frozen). Length must match output vector. */ + freezeMask: Uint8Array | null; + /** If true and prev buffer matches length, reuse it for processed output. */ + reuseBuffer: boolean; +} + +export interface OutputState { + /** Last processed output (kept here for slew/freeze logic). */ + prev: Float32Array | null; + /** Last EMA-smoothed values per output. */ + smoothed: Float32Array | null; +} + +export function defaultOutputConfig(): OutputConfig { + return { + globalCurve: 1.0, + smoothing: 0, + slewRate: Infinity, + freezeOutput: false, + freezeMask: null, + reuseBuffer: false, + }; +} + +export function defaultOutputState(): OutputState { + return { prev: null, smoothed: null }; +} + +function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number { + if (smoothing <= 0) return raw; + const effectiveDt = dt > 0 ? dt : REFERENCE_DT; + const alpha = 1 - smoothing; + const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT); + return prev + alphaEff * (raw - prev); +} + +/** + * Process raw outputs through global curve → smoothing → slew → freeze gate. + * + * @param raw raw output vector (Float32Array of size N) + * @param cfg pipeline config + * @param state prior state (use {@link defaultOutputState} first call) + * @param dtMs time since last call in milliseconds + * @returns { processed, state } with the new outputs and updated state + */ +export function processOutput( + raw: Float32Array, + cfg: OutputConfig, + state: OutputState, + dtMs: number, +): { processed: Float32Array; state: OutputState } { + const n = raw.length; + const dt = Math.max(0, dtMs / 1000); + + let prev = state.prev; + let smoothed = state.smoothed; + if (!prev || prev.length !== n) { + prev = new Float32Array(n); + // Seed from raw on first call + for (let i = 0; i < n; i++) prev[i] = clamp01(raw[i] ?? 0); + } + if (!smoothed || smoothed.length !== n) { + smoothed = new Float32Array(n); + for (let i = 0; i < n; i++) smoothed[i] = clamp01(raw[i] ?? 0); + } + + let processed: Float32Array; + if (cfg.reuseBuffer && prev.length === n) { + processed = prev; + } else { + processed = new Float32Array(n); + } + + // Stage 1: global curve (mutates a working scratch via direct compute) + const exp = cfg.globalCurve; + + if (cfg.freezeOutput) { + // Output frozen: hold prior values. + if (processed !== prev) { + processed.set(prev); + } + return { processed, state: { prev: processed, smoothed } }; + } + + for (let i = 0; i < n; i++) { + const r = clamp01(raw[i] ?? 0); + const curved = exp === 1.0 ? r : Math.pow(r, exp); + + // Per-output freeze + if (cfg.freezeMask && cfg.freezeMask[i]) { + processed[i] = prev[i] ?? curved; + continue; + } + + // Stage 2: EMA smoothing + let value = emaSmooth(smoothed[i] ?? curved, curved, cfg.smoothing, dt); + smoothed[i] = value; + + // Stage 3: slew-rate limit + if (isFinite(cfg.slewRate) && cfg.slewRate > 0) { + const maxDelta = cfg.slewRate * dt; + const delta = value - (prev[i] ?? value); + if (Math.abs(delta) > maxDelta) { + value = (prev[i] ?? value) + Math.sign(delta) * maxDelta; + } + } + + processed[i] = clamp01(value); + } + + // Update prev for next call + if (processed !== prev) { + prev = new Float32Array(processed); // copy so caller can hold processed buffer freely + } + + return { processed, state: { prev, smoothed } }; +}