Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user