feat: add live multi-plane and 3d array views

This commit is contained in:
2026-08-30 10:35:27 +09:00
parent dda72739a9
commit c289af366c
7 changed files with 1895 additions and 200 deletions
+322 -37
View File
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function logarithmicFrequencyPosition(frequencyHz) {
return Math.round(Math.log10(frequencyHz) * 1000);
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
for (const file of files) transfer.items.add(file);
@@ -102,20 +106,46 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.documentElement.dataset.beamPatternReady === "true",
"기본 수신 빔 패턴 렌더",
);
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
?.getAttribute("d");
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
"polar plot has four inner dB rings and the 0 dB ring",
);
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
const beamCuts = ["xz", "yz", "xy"];
const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
cut,
documentObject.querySelector("#beam-pattern-plot-" + cut),
]));
const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
cut,
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
]));
for (const cut of beamCuts) {
const beamPlot = beamPlots[cut];
assert(
initialBeamPaths[cut]?.startsWith("M"),
cut.toUpperCase() + " polar response path is rendered",
);
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
cut.toUpperCase() + " polar plot has four inner dB rings and the 0 dB ring",
);
assertEqual(beamPlot.dataset.cut, cut, cut.toUpperCase() + " cut provenance");
assertEqual(
beamPlot.dataset.frequencyHz,
"1000",
cut.toUpperCase() + " default analog frequency",
);
assert(
Number(beamPlot.dataset.sampleCount) >= 720,
cut.toUpperCase() + " auto angular sampling uses at least 720 directions",
);
}
assert(
Number(beamPlot.dataset.sampleCount) >= 720,
"auto angular sampling uses at least 720 directions",
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
documentObject.querySelector("#beam-pattern-surface-view").hidden,
"three polar cuts are the simultaneous default view",
);
assertEqual(
documentObject.querySelectorAll("#beam-pattern-cuts-view .beam-pattern-figure").length,
3,
"XZ, YZ, and XY figures are present together",
);
assert(
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
@@ -125,16 +155,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const initialSaveState = documentObject.querySelector("#save-state").textContent;
const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
49_125,
frequencySlider,
logarithmicFrequencyPosition(49_125),
"input",
);
await waitFor(
() => beamPlot.dataset.frequencyHz === "49125" &&
Number(beamPlot.dataset.sampleCount) > 720,
"above-Nyquist-style analog pattern with auto angular sampling",
() => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
beamCuts.every((cut) =>
Number(beamPlots[cut].dataset.frequencyHz) > 48_000 &&
beamPlots[cut].dataset.sampleCount === "720"),
"logarithmic slider input renders a bounded live preview in every cut",
);
const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow"));
assert(
previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000,
"log slider position maps to the intended above-Nyquist-style analog frequency",
);
assert(
documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"),
"log slider exposes its physical frequency value",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720),
"logarithmic slider change renders full auto resolution in every cut",
);
assert(
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
360,
);
await waitFor(
() => beamPlot.dataset.sampleCount === "360" &&
() => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족",
),
"coarse angular grid exposes unresolved metrics",
"coarse angular grid exposes unresolved metrics for the simultaneous cuts",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
documentObject.querySelector("#beam-pattern-speed-of-sound"),
"",
"input",
);
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
documentObject.querySelector("#beam-pattern-speed-of-sound"),
343,
"input",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
1_000,
frequencySlider,
logarithmicFrequencyPosition(1_000),
"input",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
beamPlot.dataset.frequencyHz === "1000",
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) =>
beamPlots[cut].dataset.frequencyHz === "1000" &&
Number(beamPlots[cut].dataset.sampleCount) >= 720),
"beam preview recovery",
);
assertEqual(
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
initialSaveState,
"beam preview settings are outside Array dirty state",
);
documentObject.querySelector("#beam-pattern-view-3d-button").click();
const beamSurface = documentObject.querySelector("#beam-pattern-surface");
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
Number(beamSurface.dataset.sampleCount) > 0,
"3D beam surface render",
);
assert(
/^\d+x\d+$/.test(beamSurface.dataset.gridShape),
"3D beam canvas records its raw sphere grid",
);
assertEqual(
documentObject.querySelector("#beam-pattern-view-3d-button").getAttribute("aria-pressed"),
"true",
"3D beam view toggle is pressed",
);
assert(
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
"raw grid samples",
),
"3D beam caption identifies raw sampled directions",
);
assert(
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
"dB-floor radial mapping",
),
"3D beam caption discloses the display-only radial mapping",
);
await nextPaint(windowObject);
const surfaceBeforeOrbit = beamSurface.toDataURL();
const beamSurfaceRect = beamSurface.getBoundingClientRect();
const beamOrbitPointerId = 71;
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 1,
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.5,
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.5,
}));
assertEqual(beamSurface.dataset.orbiting, "true", "3D beam drag begins orbiting");
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: -1,
buttons: 1,
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.7,
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.36,
}));
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 0,
}));
await nextPaint(windowObject);
assert(!beamSurface.dataset.orbiting, "3D beam drag finishes orbiting");
assert(
beamSurface.toDataURL() !== surfaceBeforeOrbit,
"3D beam surface bitmap changes after orbit rotation",
);
documentObject.querySelector("#beam-pattern-view-cuts-button").click();
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
documentObject.querySelector("#beam-pattern-surface-view").hidden,
"return to simultaneous polar cuts",
);
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
@@ -203,6 +342,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const canvas = documentObject.querySelector("#array-canvas");
assertEqual(canvas.dataset.viewMode, "2d", "array canvas starts in editable 2D mode");
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 화면 바깥쪽",
),
"2D canvas states the +Z front direction",
);
const coordinatesBeforeArray3d = selectedCoordinates(documentObject);
const microphoneCountBeforeArray3d = microphoneCount(documentObject);
documentObject.querySelector("#array-view-3d-button").click();
await waitFor(
() => canvas.dataset.viewMode === "3d" &&
canvas.getAttribute("aria-label").includes("3D 검사"),
"array 3D inspection mode",
);
assert(
documentObject.querySelector("#add-tool-button").disabled &&
documentObject.querySelector("#select-tool-button").disabled,
"array canvas edit tools are disabled in 3D inspection",
);
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 초록 축",
),
"3D canvas identifies the +Z front axis",
);
assert(
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
"3D array guidance explains that coordinate editing is disabled",
);
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "a",
}));
await nextPaint(windowObject);
assertEqual(canvas.dataset.tool, "select", "A shortcut cannot enable add in 3D inspection");
assertEqual(
microphoneCount(documentObject),
microphoneCountBeforeArray3d,
"3D inspection cannot add a microphone",
);
assertEqual(
selectedCoordinates(documentObject).join(","),
coordinatesBeforeArray3d.join(","),
"3D inspection leaves microphone coordinates unchanged",
);
const array3dBeforeOrbit = canvas.toDataURL();
const array3dRect = canvas.getBoundingClientRect();
const arrayOrbitPointerId = 72;
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 1,
clientX: array3dRect.left + array3dRect.width * 0.5,
clientY: array3dRect.top + array3dRect.height * 0.5,
}));
assertEqual(canvas.dataset.orbiting, "true", "array 3D drag begins orbiting");
canvas.dispatchEvent(new windowObject.PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: -1,
buttons: 1,
clientX: array3dRect.left + array3dRect.width * 0.68,
clientY: array3dRect.top + array3dRect.height * 0.4,
}));
canvas.dispatchEvent(new windowObject.PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 0,
}));
await nextPaint(windowObject);
assertEqual(canvas.dataset.orbiting, "false", "array 3D drag finishes orbiting");
assert(
canvas.toDataURL() !== array3dBeforeOrbit,
"array 3D canvas bitmap changes after orbit rotation",
);
documentObject.querySelector("#array-view-2d-button").click();
await waitFor(
() => canvas.dataset.viewMode === "2d" &&
canvas.getAttribute("aria-label").includes("평면 편집"),
"return to array 2D editing",
);
assert(
!documentObject.querySelector("#add-tool-button").disabled &&
!documentObject.querySelector("#select-tool-button").disabled,
"array canvas edit tools are restored in 2D",
);
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 화면 바깥쪽",
),
"returning to 2D restores its +Z front cue",
);
documentObject.querySelector(
'#microphone-list [data-microphone-id="mic-001"]',
).click();
assertEqual(
selectedCoordinates(documentObject).join(","),
coordinatesBeforeArray3d.join(","),
"2D edit resumes from the original microphone coordinates",
);
const defaultArray = createArrayDefinition();
const initialCanvasRect = canvas.getBoundingClientRect();
const initialViewport = fitArrayViewport(
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
dispatchDragPointer("pointerup", 36, -24, 0);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
initialBeamPath,
"beam pattern follows edited microphone geometry",
beamCuts.every((cut) =>
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
initialBeamPaths[cut]),
"all beam-pattern cuts follow edited microphone geometry",
);
const draggedPosition = selectedCoordinates(documentObject);
assert(
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px",
);
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
.getBoundingClientRect();
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
const compactBeamPlotRects = beamCuts.map((cut) =>
beamPlots[cut].getBoundingClientRect());
assert(
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
"compact polar plot stays inside its analysis card",
compactBeamPlotRects.every((rect) =>
rect.left >= compactBeamCardRect.left - 1 &&
rect.right <= compactBeamCardRect.right + 1),
"all compact polar plots stay inside their analysis card",
);
assert(
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
"compact polar plot remains square",
compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
"all compact polar plots remain square",
);
assert(
compactBeamPlotRects.every(
(rect, index) => index === 0 || rect.top > compactBeamPlotRects[index - 1].bottom,
),
"compact XZ, YZ, and XY plots stack without overlap",
);
const compactBeamFields = [
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
].map((field) => field.getBoundingClientRect());
].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
assert(
compactBeamFields.every(
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
),
"compact beam controls stack without clipping",
);
documentObject.querySelector("#beam-pattern-view-3d-button").click();
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
Number(beamSurface.dataset.sampleCount) > 0,
"compact 3D beam surface",
);
await nextPaint(windowObject);
const compactBeamSurfaceRect = beamSurface.getBoundingClientRect();
const compactBeamSurfaceCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
assert(
compactBeamSurfaceRect.left >= compactBeamSurfaceCardRect.left - 1 &&
compactBeamSurfaceRect.right <= compactBeamSurfaceCardRect.right + 1,
"compact 3D beam surface stays inside its analysis card",
);
assertNear(
compactBeamSurfaceRect.width / compactBeamSurfaceRect.height,
16 / 10,
0.03,
"compact 3D beam surface preserves its aspect ratio",
);
assert(
documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1,
"3D beam mode has no horizontal overflow at 390px",
);
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px",
+27
View File
@@ -7,6 +7,7 @@ import {
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js";
function assertNear(actual, expected, tolerance, label) {
@@ -139,3 +140,29 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
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 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
const frontView = {
yaw: 0,
pitch: 0,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
const positiveX = projectArrayPosition3d([1, 0, 0], frontView);
const positiveZ = projectArrayPosition3d([0, 0, 1], frontView);
const positiveY = projectArrayPosition3d([0, 1, 0], frontView);
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 FRONT projects upward");
assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth");
const edgeView = { ...frontView, pitch: Math.PI / 2 };
const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView);
assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically");
assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis");
});