Files
micarrsim/tests/unit/array-builder-canvas.test.js

292 lines
11 KiB
JavaScript

import {
arrayPositionToCanvas,
canvasPointToArray,
eventToArrayPosition,
eventToCanvasPoint,
fitArrayViewport,
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js";
import {
ORBIT_VIEW_DIRECTIONS,
orbitForViewDirection,
} from "/apps/array-builder/orbit-view.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
}
function projectionViewport(orbit) {
return {
...orbit,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
}
function fixture() {
return {
aperture: { width: 0.4, height: 0.2 },
microphones: [
{ id: "mic-001", position: [-0.1, -0.05, 0] },
{ id: "mic-002", position: [0.1, 0.05, 0.01] },
],
};
}
test("Array canvas fit은 x/y에 동일 축척을 사용하고 좌표를 왕복한다", () => {
const viewport = fitArrayViewport(800, 420, fixture());
const point = [0.075, -0.025, 0.012];
const pixel = arrayPositionToCanvas(point, viewport);
const roundTrip = canvasPointToArray(pixel, viewport, { z: point[2] });
assertNear(roundTrip[0], point[0], 1e-12, "x round trip");
assertNear(roundTrip[1], point[1], 1e-12, "y round trip");
assertNear(roundTrip[2], point[2], 1e-12, "z preserved");
const oneCentimeterX = arrayPositionToCanvas([0.01, 0, 0], viewport).x -
arrayPositionToCanvas([0, 0, 0], viewport).x;
const oneCentimeterY = arrayPositionToCanvas([0, 0.01, 0], viewport).y -
arrayPositionToCanvas([0, 0, 0], viewport).y;
assertNear(Math.abs(oneCentimeterX), Math.abs(oneCentimeterY), 1e-12, "equal x/y scale");
});
test("Array canvas 좌표는 화면 위쪽을 양의 y로 해석한다", () => {
const viewport = fitArrayViewport(500, 500, fixture());
const upper = canvasPointToArray(
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y },
viewport,
);
const lower = canvasPointToArray(
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y + viewport.plot.height },
viewport,
);
assert(upper[1] > lower[1], "canvas top maps to larger array y");
assertNear(upper[1], viewport.yMax, 1e-12, "top is yMax");
assertNear(lower[1], viewport.yMin, 1e-12, "bottom is yMin");
});
test("PointerEvent-like 좌표 변환은 DOM 없이 canvas와 plot 경계에 clamp할 수 있다", () => {
const viewport = fitArrayViewport(640, 360, fixture());
const rect = { left: 100, top: 50, width: 640, height: 360 };
const canvasPoint = eventToCanvasPoint({ clientX: 50, clientY: 500 }, rect, { clamp: true });
assertEqual(canvasPoint.x, 0, "client x clamps to canvas left");
assertEqual(canvasPoint.y, 360, "client y clamps to canvas bottom");
const position = eventToArrayPosition(
{ clientX: -500, clientY: 900 },
rect,
viewport,
{ clamp: true, clampToCanvas: true, z: 0.02 },
);
assertNear(position[0], viewport.xMin, 1e-12, "event x clamps to plot xMin");
assertNear(position[1], viewport.yMin, 1e-12, "event y clamps to plot yMin");
assertEqual(position[2], 0.02, "event conversion carries z");
});
test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채널 index를 돌려준다", () => {
const arrayDefinition = fixture();
const viewport = fitArrayViewport(800, 420, arrayDefinition);
const micPixel = arrayPositionToCanvas(arrayDefinition.microphones[1].position, viewport);
const hit = hitTestMicrophone(
{ x: micPixel.x + 3, y: micPixel.y - 2 },
arrayDefinition.microphones,
viewport,
8,
);
assertEqual(hit.id, "mic-002", "closest microphone id");
assertEqual(hit.index, 1, "physical channel index");
assertNear(hit.distancePx, Math.hypot(3, 2), 1e-12, "pixel distance");
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
});
test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => {
const viewport = {
plot: { x: 0, y: 0 },
xMin: 0,
yMax: 100,
scale: 2,
};
const microphones = [
{ id: "overflow", position: [Number.MAX_VALUE, 0, 0] },
{ id: "normal", position: [5, 5, 0] },
];
assertEqual(
arrayPositionToCanvas(microphones[0].position, viewport).x,
Infinity,
"fixture overflows after viewport conversion",
);
assertEqual(
hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id,
"normal",
"finite microphone remains hittable",
);
});
test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => {
const arrayDefinition = fixture();
arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0];
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0];
const viewport = fitArrayViewport(800, 420, arrayDefinition);
for (const [name, value] of Object.entries({
scale: viewport.scale,
metersPerPixel: viewport.metersPerPixel,
xMin: viewport.xMin,
xMax: viewport.xMax,
yMin: viewport.yMin,
yMax: viewport.yMax,
})) {
assert(Number.isFinite(value), name + " remains finite");
}
assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin");
});
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
for (const step of candidates) {
const magnitude = 10 ** Math.floor(Math.log10(step));
const normalized = Math.round(step / magnitude);
assert([1, 2, 5, 10].includes(normalized), "grid step uses 1/2/5 decade");
}
assertEqual(keyboardNudgeForKey("ArrowLeft", 0.002).dx, -0.002, "left nudge");
assertEqual(keyboardNudgeForKey("ArrowUp", 0.002).dy, 0.002, "up is positive y");
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
});
test("Array canvas yaw=0, pitch=0 projection은 -Y축 위치에서 원점을 본다", () => {
const negativeYAxisView = {
yaw: 0,
pitch: 0,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
const positiveX = projectArrayPosition3d([1, 0, 0], negativeYAxisView);
const positiveZ = projectArrayPosition3d([0, 0, 1], negativeYAxisView);
const positiveY = projectArrayPosition3d([0, 1, 0], negativeYAxisView);
const negativeY = projectArrayPosition3d([0, -1, 0], negativeYAxisView);
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
assertNear(positiveZ.y, 20, 1e-12, "+Z projects upward");
assertNear(negativeY.depth, 1, 1e-12, "-Y is on the viewer side");
assertNear(positiveY.depth, -1, 1e-12, "+Y is behind the origin");
});
test("Array canvas의 여섯 axis preset은 해당 축 위치에서 원점을 본다", () => {
assertEqual(
ORBIT_VIEW_DIRECTIONS.join(","),
"iso,+x,-x,+y,-y,+z,-z",
"public preset order",
);
const axisVectors = {
"+x": [1, 0, 0],
"-x": [-1, 0, 0],
"+y": [0, 1, 0],
"-y": [0, -1, 0],
"+z": [0, 0, 1],
"-z": [0, 0, -1],
};
for (const direction of ORBIT_VIEW_DIRECTIONS.slice(1)) {
const viewport = projectionViewport(orbitForViewDirection(direction));
const cameraSide = projectArrayPosition3d(axisVectors[direction], viewport);
const farSide = projectArrayPosition3d(
axisVectors[direction].map((coordinate) => -coordinate),
viewport,
);
assertNear(cameraSide.x, 200, 1e-12, direction + " camera-side x");
assertNear(cameraSide.y, 120, 1e-12, direction + " camera-side y");
assertNear(cameraSide.depth, 1, 1e-12, direction + " camera-side depth");
assertNear(farSide.x, 200, 1e-12, direction + " far-side x");
assertNear(farSide.y, 120, 1e-12, direction + " far-side y");
assertNear(farSide.depth, -1, 1e-12, direction + " far-side depth");
}
});
test("Array canvas +Z preset은 +X를 오른쪽, +Y를 위쪽으로 투영한다", () => {
const positiveZView = projectionViewport(orbitForViewDirection("+z"));
const positiveX = projectArrayPosition3d([1, 0, 0], positiveZView);
const positiveY = projectArrayPosition3d([0, 1, 0], positiveZView);
const positiveZ = projectArrayPosition3d([0, 0, 1], positiveZView);
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
assertNear(positiveX.y, 120, 1e-12, "+X stays vertically centered");
assertNear(positiveY.x, 200, 1e-12, "+Y stays horizontally centered");
assertNear(positiveY.y, 20, 1e-12, "+Y projects up");
assertNear(positiveZ.x, 200, 1e-12, "+Z stays horizontally centered");
assertNear(positiveZ.y, 120, 1e-12, "+Z stays vertically centered");
assertNear(positiveZ.depth, 1, 1e-12, "+Z points toward the viewer");
});
test("Array canvas iso preset은 유한하고 비퇴화된 orthographic view다", () => {
const implicitIso = orbitForViewDirection();
const explicitIso = orbitForViewDirection("iso");
assert(Number.isFinite(explicitIso.yaw), "iso yaw is finite");
assert(Number.isFinite(explicitIso.pitch), "iso pitch is finite");
assert(Math.abs(explicitIso.pitch) < Math.PI / 2, "iso pitch avoids a pole");
assertNear(explicitIso.yaw, -Math.PI / 4, 1e-12, "iso yaw is canonical");
assertNear(
explicitIso.pitch,
Math.asin(1 / Math.sqrt(3)),
1e-12,
"iso pitch is equally inclined to XYZ",
);
assertNear(implicitIso.yaw, explicitIso.yaw, 1e-12, "default yaw is iso");
assertNear(implicitIso.pitch, explicitIso.pitch, 1e-12, "default pitch is iso");
const viewport = projectionViewport(explicitIso);
const basis = [
projectArrayPosition3d([1, 0, 0], viewport),
projectArrayPosition3d([0, 1, 0], viewport),
projectArrayPosition3d([0, 0, 1], viewport),
];
for (const [index, point] of basis.entries()) {
assert(
[point.x, point.y, point.depth].every(Number.isFinite),
"iso basis projection " + index + " is finite",
);
assert(
Math.hypot(point.x - 200, point.y - 120) > 1,
"iso basis projection " + index + " does not collapse to the origin",
);
}
const xVector = [basis[0].x - 200, basis[0].y - 120];
const yVector = [basis[1].x - 200, basis[1].y - 120];
const zVector = [basis[2].x - 200, basis[2].y - 120];
assertNear(
Math.hypot(...xVector),
Math.hypot(...yVector),
1e-10,
"iso X/Y projected unit lengths match",
);
assertNear(
Math.hypot(...xVector),
Math.hypot(...zVector),
1e-10,
"iso X/Z projected unit lengths match",
);
assert(
Math.abs(xVector[0] * yVector[1] - xVector[1] * yVector[0]) > 1,
"iso projected X/Y basis spans a plane",
);
});
test("Array canvas orbit preset은 알 수 없는 direction을 거부한다", () => {
let error = null;
try {
orbitForViewDirection("front");
} catch (caught) {
error = caught;
}
assert(error instanceof RangeError, "invalid direction throws RangeError");
assert(error.message.includes("iso"), "error lists the supported directions");
assert(error.message.includes("+z"), "error includes +Z");
});