One-core-engine P4.3/P4.4: the input/output pipeline processing and the curve
catalog now live in the C++/WASM core (nisps/pipeline/*, nisps/core/math.hpp).
The TS ports are deleted and the browser drives the WASM chains.
Engine:
- WasmIML owns a nisps_pipeline_create handle + bridge buffers and exposes
setInputConfig (TS InputConfig → 15-float wire), processInput, resetInput,
setOutputConfig (Infinity slew → 0), setOutputFreezeMask, processOutput
(in place), resetOutput, curveApply, curveApplyBatch (chunked). Handle +
buffers created in init_, freed in dispose, output-sized buffers realloc'd
on reshape.
- Spine routes setInputs through iml.processInput/processOutput (state lives
C++-side); config source-of-truth stays TS-side and is pushed on attach /
setInputConfig / setOutputConfig. Preserves ?debug=1 fixed-dt determinism
(same dt fed to the WASM calls). EngineApi gains setInputConfig/
setOutputConfig/curveApply/curveApplyBatch.
- New types-only modules: pipeline-types.ts (InputConfig/OutputConfig +
defaults + wire int mappers) and curve-catalog.ts (CurveName + name→id).
types.ts declares the pipeline/curve C ABI. engine barrel updated.
- DELETED src/engine/{input-pipeline,output-pipeline,curves}.ts.
Tests (P4.4 gate — recorded-gesture regression):
- pipeline-golden.test.ts now loads the built WASM (indirect-eval shim,
tests/wasm-load.ts) and drives the frozen gesture/output fixtures through the
C++ chains, honouring the per-event dt clock contract. Tolerance 1e-5
(non-momentum drift <5e-7). The 3 momentum configs carry 1e-2: proven-inherent
f32 drift (a byte-faithful f32 port of the exact original algorithm matches
the WASM to <6e-8 while both diverge from the f64 capture by ~7-9e-3), NOT a
core bug.
- curves-golden.json: linear/square/sqrt/centered_power kept as the original
f64 captures (C++ matches within <3e-8); exp/log/sigmoid/cubic RE-BASELINED
from the WASM (deliberate switch to firmware-exact k=1 exp/log, slope-6
sigmoid, true cubic x^3). Provenance recorded in-file.
- _generate.ts rebuilt as the WASM curve re-baseline tool; pipeline-golden-lib
trimmed to pure data builders.
Docs: fixtures/README.md + manifold/ONBOARDING.md updated.
Gates: typecheck, bun test (9), vite build, playwright e2e (27) all green.
112 lines
5.2 KiB
TypeScript
112 lines
5.2 KiB
TypeScript
/**
|
|
* Load the built nisps WASM module under `bun test` / a Bun script, without a
|
|
* DOM. The Emscripten glue (`manifold/public/nisps.js`) is MODULARIZE output
|
|
* WITHOUT ES exports — it assigns a global `createNispsModule` with only
|
|
* CommonJS/AMD fallbacks. Neither `require()` nor `import()` extracts the
|
|
* factory cleanly (the public/ dir is a `type:module` sub-package), so we read
|
|
* the glue as text and indirect-eval it inside a thin shim that returns the
|
|
* factory — the same technique as tests/cpp/parity_wasm.mjs.
|
|
*
|
|
* Returns a friendly wrapper around the pipeline + curve C ABI used by the
|
|
* golden tests (the ML surface is exercised elsewhere).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
interface EmModule {
|
|
HEAPF32: Float32Array;
|
|
HEAPU8: Uint8Array;
|
|
_malloc(bytes: number): number;
|
|
_free(ptr: number): void;
|
|
cwrap(name: string, ret: string | null, args: string[]): (...a: number[]) => number;
|
|
}
|
|
type Factory = (opts: { wasmBinary: Uint8Array }) => Promise<EmModule>;
|
|
|
|
export interface PipelineWasm {
|
|
module: EmModule;
|
|
pipelineCreate(): number;
|
|
pipelineDestroy(p: number): void;
|
|
inputSetConfig(p: number, cfg: ArrayLike<number>): void;
|
|
/** Returns { x, y, frozen }. */
|
|
inputProcess(p: number, x: number, y: number, dtSeconds: number): { x: number; y: number; frozen: boolean };
|
|
inputReset(p: number): void;
|
|
outputSetConfig(p: number, globalCurve: number, smoothing: number, slewRate: number, freeze: boolean): void;
|
|
outputSetFreezeMask(p: number, mask: Uint8Array | null): void;
|
|
/** Process `vec` in place (first vec.length floats). */
|
|
outputProcess(p: number, vec: Float32Array, dtSeconds: number): void;
|
|
outputReset(p: number): void;
|
|
curveApply(id: number, x: number, param?: number): number;
|
|
}
|
|
|
|
let cached: PipelineWasm | null = null;
|
|
|
|
export async function loadPipelineWasm(): Promise<PipelineWasm> {
|
|
if (cached) return cached;
|
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
const gluePath = join(dir, '..', 'public', 'nisps.js');
|
|
const wasmPath = join(dir, '..', 'public', 'nisps.wasm');
|
|
const source = readFileSync(gluePath, 'utf8');
|
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
const factory = new Function(
|
|
'module', 'exports',
|
|
`${source}\n;return typeof createNispsModule === 'function' ? createNispsModule : null;`,
|
|
)({ exports: {} }, {}) as Factory | null;
|
|
if (typeof factory !== 'function') throw new Error('[wasm-load] createNispsModule not found in glue');
|
|
const wasmBinary = readFileSync(wasmPath);
|
|
const M = await factory({ wasmBinary });
|
|
|
|
const cPipelineCreate = M.cwrap('nisps_pipeline_create', 'number', []);
|
|
const cPipelineDestroy = M.cwrap('nisps_pipeline_destroy', null, ['number']);
|
|
const cInputSetConfig = M.cwrap('nisps_input_set_config', null, ['number', 'number', 'number']);
|
|
const cInputProcess = M.cwrap('nisps_input_process', 'number', ['number', 'number', 'number', 'number', 'number']);
|
|
const cInputReset = M.cwrap('nisps_input_reset', null, ['number']);
|
|
const cOutputSetConfig = M.cwrap('nisps_output_set_config', null, ['number', 'number', 'number', 'number', 'number']);
|
|
const cOutputSetFreezeMask = M.cwrap('nisps_output_set_freeze_mask', null, ['number', 'number', 'number']);
|
|
const cOutputProcess = M.cwrap('nisps_output_process', null, ['number', 'number', 'number', 'number']);
|
|
const cOutputReset = M.cwrap('nisps_output_reset', null, ['number']);
|
|
const cCurveApply = M.cwrap('nisps_curve_apply', 'number', ['number', 'number', 'number']);
|
|
|
|
// Reusable scratch buffers (sized to the fixtures' needs).
|
|
const cfgPtr = M._malloc(15 * 4);
|
|
const xyPtr = M._malloc(2 * 4);
|
|
const OUT_CAP = 64;
|
|
const outPtr = M._malloc(OUT_CAP * 4);
|
|
const maskPtr = M._malloc(OUT_CAP);
|
|
|
|
cached = {
|
|
module: M,
|
|
pipelineCreate: () => cPipelineCreate(),
|
|
pipelineDestroy: (p) => cPipelineDestroy(p),
|
|
inputSetConfig(p, cfg) {
|
|
const v = new Float32Array(M.HEAPF32.buffer, cfgPtr, 15);
|
|
for (let i = 0; i < 15; i++) v[i] = cfg[i] ?? 0;
|
|
cInputSetConfig(p, cfgPtr, 15);
|
|
},
|
|
inputProcess(p, x, y, dtSeconds) {
|
|
const frozen = cInputProcess(p, x, y, dtSeconds, xyPtr);
|
|
const v = new Float32Array(M.HEAPF32.buffer, xyPtr, 2);
|
|
return { x: v[0]!, y: v[1]!, frozen: frozen === 1 };
|
|
},
|
|
inputReset: (p) => cInputReset(p),
|
|
outputSetConfig(p, globalCurve, smoothing, slewRate, freeze) {
|
|
const slew = Number.isFinite(slewRate) ? slewRate : 0;
|
|
cOutputSetConfig(p, globalCurve, smoothing, slew, freeze ? 1 : 0);
|
|
},
|
|
outputSetFreezeMask(p, mask) {
|
|
if (!mask || mask.length === 0) { cOutputSetFreezeMask(p, 0, 0); return; }
|
|
const n = Math.min(mask.length, OUT_CAP);
|
|
new Uint8Array(M.HEAPU8.buffer, maskPtr, n).set(mask.subarray(0, n));
|
|
cOutputSetFreezeMask(p, maskPtr, n);
|
|
},
|
|
outputProcess(p, vec, dtSeconds) {
|
|
const n = Math.min(vec.length, OUT_CAP);
|
|
new Float32Array(M.HEAPF32.buffer, outPtr, n).set(vec.subarray(0, n));
|
|
cOutputProcess(p, outPtr, n, dtSeconds);
|
|
vec.set(new Float32Array(M.HEAPF32.buffer, outPtr, n).subarray(0, n));
|
|
},
|
|
outputReset: (p) => cOutputReset(p),
|
|
curveApply: (id, x, param = 0) => cCurveApply(id, x, param),
|
|
};
|
|
return cached;
|
|
}
|