148 lines
4.6 KiB
JavaScript
148 lines
4.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
archiveWave,
|
|
extraction,
|
|
selectionWave,
|
|
baselineSelectionWave,
|
|
rippleEnvelope,
|
|
settlingWave,
|
|
damp,
|
|
idleWave,
|
|
} from "../src/motion.ts";
|
|
import { selectionWave as historicalWave } from "../reference/baseline-motion.ts";
|
|
|
|
// The selected production motion is the original signed wave, including troughs.
|
|
let baselineTrough = false;
|
|
for (let frame = -1; frame <= 200; frame++) {
|
|
for (let distance = 0; distance <= 32; distance += 0.5) {
|
|
const restored = baselineSelectionWave(distance, frame / 60);
|
|
assert.equal(restored, historicalWave(distance, frame / 60));
|
|
baselineTrough ||= restored < -0.01;
|
|
}
|
|
}
|
|
assert.ok(baselineTrough, "The original negative trough is restored");
|
|
|
|
let idleRange = 0;
|
|
for (let lane = 0; lane < 5; lane++) {
|
|
for (let row = 0; row < 32; row++) {
|
|
for (let frame = 0; frame < 60 * 13; frame++) {
|
|
const a = idleWave(row, lane, frame / 60);
|
|
const b = idleWave(row, lane, (frame + 1) / 60);
|
|
idleRange = Math.max(idleRange, Math.abs(a));
|
|
assert.ok(
|
|
Math.abs(a) < 3.7 * 0.03,
|
|
"Idle lift stays below 3% of card height",
|
|
);
|
|
assert.ok(
|
|
Math.abs(b - a) * (1080 / 7.33) < 0.21,
|
|
"Idle motion remains subpixel per frame",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert.ok(idleRange > 0.075, "Idle field remains perceptible without input");
|
|
|
|
const peak = (t) =>
|
|
Array.from({ length: 32 }, (_, row) => archiveWave(row, 2, t)).reduce(
|
|
(best, y, row, values) => (y > values[best] ? row : best),
|
|
0,
|
|
);
|
|
assert.ok(peak(23.3) > peak(22.7) + 6, "First crest must travel across rows");
|
|
assert.ok(peak(24.8) < peak(24.2) - 8, "Second crest must return across rows");
|
|
let maxFrameDelta = 0;
|
|
for (let frame = 550; frame < 800; frame++) {
|
|
for (let row = 0; row < 32; row++)
|
|
for (let lane = 0; lane < 5; lane++) {
|
|
const a = archiveWave(row, lane, frame / 25);
|
|
const b = archiveWave(row, lane, (frame + 1) / 25);
|
|
assert.ok(Number.isFinite(a));
|
|
maxFrameDelta = Math.max(maxFrameDelta, Math.abs(b - a));
|
|
}
|
|
}
|
|
assert.ok(maxFrameDelta < 0.7, "25 fps samples must not teleport");
|
|
assert.ok(
|
|
Math.abs(extraction(26.6) - extraction(27.2)) < 0.01,
|
|
"Pause between extraction phases",
|
|
);
|
|
assert.ok(extraction(29) > 3 && extraction(25.1) === 0);
|
|
assert.ok(
|
|
Math.abs(settlingWave(2, 26.1) - settlingWave(2, 26.5)) > 0.01,
|
|
"Neighbors keep moving during the first extraction hold",
|
|
);
|
|
assert.ok(selectionWave(8, 1) > 0.1, "Click ripple reaches neighboring rows");
|
|
for (let frame = 0; frame <= 200; frame++) {
|
|
for (let distance = 0; distance <= 32; distance += 0.5) {
|
|
const y = selectionWave(distance, frame / 60);
|
|
const age = frame / 60;
|
|
const ramp = Math.max(0, Math.min(1, age / 0.2));
|
|
const original =
|
|
0.8 *
|
|
ramp ** 3 *
|
|
(10 + ramp * (-15 + 6 * ramp)) *
|
|
Math.exp(-age * 1.15) *
|
|
Math.cos((distance - age * 8) * 0.58) *
|
|
Math.exp(-0.5 * ((distance - age * 8) / 3.4) ** 2);
|
|
assert.ok(
|
|
y >= 0 && y <= 0.8,
|
|
"Selection pulse cannot create a negative trough",
|
|
);
|
|
if (age <= 3.2 && original > 0)
|
|
assert.ok(
|
|
Math.abs(y - original) < 1e-12,
|
|
"Positive crests retain the baseline amplitude and timing",
|
|
);
|
|
}
|
|
}
|
|
const ripple = (distance, age) =>
|
|
selectionWave(distance, age) * rippleEnvelope(distance, age);
|
|
for (let frame = 0; frame <= 200; frame++) {
|
|
assert.equal(
|
|
ripple(0, frame / 60),
|
|
0,
|
|
"The selected source cannot bounce on its own ripple",
|
|
);
|
|
}
|
|
for (const distance of [3, 5, 8, 12]) {
|
|
const crestTime = distance / 8;
|
|
assert.equal(
|
|
ripple(distance, crestTime),
|
|
selectionWave(distance, crestTime),
|
|
"The outward crest keeps its strength away from the source",
|
|
);
|
|
const edge = (distance - Math.PI / (2 * 0.58)) / 8;
|
|
const epsilon = 1e-5;
|
|
const velocity =
|
|
(ripple(distance, edge + epsilon) - ripple(distance, edge - epsilon)) /
|
|
(2 * epsilon);
|
|
assert.ok(
|
|
Math.abs(velocity) < 0.001,
|
|
"Ripple edges approach rest without a velocity snap",
|
|
);
|
|
}
|
|
const coarse = { value: 5, velocity: -2 },
|
|
fine = { ...coarse };
|
|
for (let i = 0; i < 30; i++) damp(coarse, -3, 4, 1 / 30);
|
|
for (let i = 0; i < 120; i++) damp(fine, -3, 4, 1 / 120);
|
|
assert.ok(
|
|
Math.abs(coarse.value - fine.value) < 1e-9,
|
|
"Spring must be frame-rate independent",
|
|
);
|
|
const before = coarse.value;
|
|
damp(coarse, 6, 4, 1 / 120);
|
|
assert.ok(
|
|
Math.abs(coarse.value - before) < 0.05,
|
|
"Retargeting must preserve position continuity",
|
|
);
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
forwardPeaks: [peak(22.7), peak(23.3)],
|
|
returnPeaks: [peak(24.2), peak(24.8)],
|
|
maxFrameDelta,
|
|
checks: "passed",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|