feat: add axis-aligned 3d camera views
This commit is contained in:
@@ -279,6 +279,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"3D beam caption discloses the display-only radial mapping",
|
||||
);
|
||||
await nextPaint(windowObject);
|
||||
const beamAxisView = documentObject.querySelector("#beam-pattern-axis-view");
|
||||
assertEqual(beamAxisView.value, "iso", "3D beam surface starts in isometric view");
|
||||
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam canvas exposes isometric provenance");
|
||||
const beamComputeBeforeAxisView =
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs;
|
||||
const beamFrequencyBeforeAxisView = beamSurface.dataset.frequencyHz;
|
||||
const surfaceBeforeAxisView = beamSurface.toDataURL();
|
||||
beamAxisView.focus();
|
||||
dispatchValue(windowObject, beamAxisView, "+z");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "+z", "beam surface aligns from +Z");
|
||||
assert(
|
||||
beamSurface.toDataURL() !== surfaceBeforeAxisView,
|
||||
"beam surface bitmap changes for the exact +Z camera",
|
||||
);
|
||||
dispatchValue(windowObject, beamAxisView, "+x");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "+x", "beam surface aligns from +X");
|
||||
assertEqual(
|
||||
documentObject.activeElement,
|
||||
beamAxisView,
|
||||
"beam axis selector keeps keyboard focus for consecutive comparisons",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs,
|
||||
beamComputeBeforeAxisView,
|
||||
"beam camera changes do not recompute the physical response",
|
||||
);
|
||||
assertEqual(
|
||||
beamSurface.dataset.frequencyHz,
|
||||
beamFrequencyBeforeAxisView,
|
||||
"beam camera changes preserve the evaluated analog frequency",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
initialSaveState,
|
||||
"beam camera changes stay outside Array dirty state",
|
||||
);
|
||||
const surfaceBeforeOrbit = beamSurface.toDataURL();
|
||||
const beamSurfaceRect = beamSurface.getBoundingClientRect();
|
||||
const beamOrbitPointerId = 71;
|
||||
@@ -317,6 +355,21 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
beamSurface.toDataURL() !== surfaceBeforeOrbit,
|
||||
"3D beam surface bitmap changes after orbit rotation",
|
||||
);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "custom", "beam drag enters free-orbit mode");
|
||||
assertEqual(beamAxisView.value, "custom", "beam view selector reflects free orbit");
|
||||
beamSurface.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "r",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam R shortcut restores isometric view");
|
||||
assertEqual(beamAxisView.value, "iso", "beam selector follows the R shortcut");
|
||||
assertEqual(
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs,
|
||||
beamComputeBeforeAxisView,
|
||||
"beam camera reset also avoids a physical-response recompute",
|
||||
);
|
||||
documentObject.querySelector("#beam-pattern-view-cuts-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
|
||||
@@ -364,9 +417,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-front-badge").textContent.includes(
|
||||
"+Z FRONT · 초록 축",
|
||||
"시점 등각",
|
||||
),
|
||||
"3D canvas identifies the +Z front axis",
|
||||
"3D canvas identifies its isometric camera",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
|
||||
@@ -389,6 +442,41 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
coordinatesBeforeArray3d.join(","),
|
||||
"3D inspection leaves microphone coordinates unchanged",
|
||||
);
|
||||
const arrayAxisControl = documentObject.querySelector("#array-axis-view-control");
|
||||
const arrayAxisView = documentObject.querySelector("#array-axis-view");
|
||||
assert(!arrayAxisControl.hidden, "array axis selector appears only for 3D inspection");
|
||||
assertEqual(arrayAxisView.value, "iso", "array 3D starts in isometric view");
|
||||
const arraySaveStateBeforeAxisView =
|
||||
documentObject.querySelector("#save-state").textContent;
|
||||
const array3dBeforeAxisView = canvas.toDataURL();
|
||||
arrayAxisView.focus();
|
||||
dispatchValue(windowObject, arrayAxisView, "+z");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "+z", "array canvas aligns from +Z FRONT");
|
||||
dispatchValue(windowObject, arrayAxisView, "+x");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "+x", "array canvas aligns from +X");
|
||||
assertEqual(
|
||||
documentObject.activeElement,
|
||||
arrayAxisView,
|
||||
"array axis selector keeps keyboard focus for consecutive comparisons",
|
||||
);
|
||||
assert(
|
||||
canvas.toDataURL() !== array3dBeforeAxisView,
|
||||
"array 3D bitmap changes after axis alignment",
|
||||
);
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
coordinatesBeforeArray3d.join(","),
|
||||
"axis camera changes leave microphone coordinates unchanged",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
arraySaveStateBeforeAxisView,
|
||||
"axis camera changes do not dirty the Array document",
|
||||
);
|
||||
const selectionBeforeArrayOrbit = documentObject
|
||||
.querySelector('#microphone-list [aria-selected="true"]')?.dataset.microphoneId;
|
||||
const array3dBeforeOrbit = canvas.toDataURL();
|
||||
const array3dRect = canvas.getBoundingClientRect();
|
||||
const arrayOrbitPointerId = 72;
|
||||
@@ -427,12 +515,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
canvas.toDataURL() !== array3dBeforeOrbit,
|
||||
"array 3D canvas bitmap changes after orbit rotation",
|
||||
);
|
||||
assertEqual(canvas.dataset.viewAxis, "custom", "array drag enters free-orbit mode");
|
||||
assertEqual(arrayAxisView.value, "custom", "array view selector reflects free orbit");
|
||||
assertEqual(
|
||||
documentObject.querySelector('#microphone-list [aria-selected="true"]')
|
||||
?.dataset.microphoneId,
|
||||
selectionBeforeArrayOrbit,
|
||||
"array orbit drag preserves the current channel selection",
|
||||
);
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "r",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "iso", "array R shortcut restores isometric view");
|
||||
assertEqual(arrayAxisView.value, "iso", "array selector follows the R shortcut");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
arraySaveStateBeforeAxisView,
|
||||
"array camera reset stays outside Array dirty state",
|
||||
);
|
||||
documentObject.querySelector("#array-view-2d-button").click();
|
||||
await waitFor(
|
||||
() => canvas.dataset.viewMode === "2d" &&
|
||||
canvas.getAttribute("aria-label").includes("평면 편집"),
|
||||
"return to array 2D editing",
|
||||
);
|
||||
assert(arrayAxisControl.hidden, "array axis selector hides in 2D editing");
|
||||
assert(
|
||||
!documentObject.querySelector("#add-tool-button").disabled &&
|
||||
!documentObject.querySelector("#select-tool-button").disabled,
|
||||
@@ -1138,6 +1248,18 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
0.03,
|
||||
"compact 3D beam surface preserves its aspect ratio",
|
||||
);
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(beamSurface).touchAction,
|
||||
"pan-y",
|
||||
"compact beam 3D view preserves vertical page scrolling",
|
||||
);
|
||||
const compactBeamAxisRect = documentObject.querySelector("#beam-pattern-axis-view")
|
||||
.getBoundingClientRect();
|
||||
assert(
|
||||
compactBeamAxisRect.left >= compactBeamSurfaceCardRect.left - 1 &&
|
||||
compactBeamAxisRect.right <= compactBeamSurfaceCardRect.right + 1,
|
||||
"compact beam axis selector stays inside its analysis card",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
@@ -1147,6 +1269,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||
"Array canvas remains usable at 390px",
|
||||
);
|
||||
documentObject.querySelector("#array-view-3d-button").click();
|
||||
await waitFor(() => canvas.dataset.viewMode === "3d", "compact array 3D mode");
|
||||
const compactAxisControlRect = arrayAxisControl.getBoundingClientRect();
|
||||
const compactToolbarRect = documentObject.querySelector(".workspace-toolbar")
|
||||
.getBoundingClientRect();
|
||||
assert(
|
||||
compactAxisControlRect.left >= compactToolbarRect.left - 1 &&
|
||||
compactAxisControlRect.right <= compactToolbarRect.right + 1,
|
||||
"compact array axis selector stays inside its toolbar",
|
||||
);
|
||||
const compactFrontBadge = documentObject.querySelector("#canvas-front-badge");
|
||||
assert(
|
||||
compactFrontBadge.scrollWidth <= compactFrontBadge.clientWidth + 1 &&
|
||||
windowObject.getComputedStyle(compactFrontBadge).whiteSpace !== "nowrap",
|
||||
"compact 3D direction badge wraps without clipping axis and FRONT cues",
|
||||
);
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(canvas).touchAction,
|
||||
"pan-y",
|
||||
"compact array 3D view preserves vertical page scrolling",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
"array axis selector creates no horizontal overflow at 390px",
|
||||
);
|
||||
documentObject.querySelector("#array-view-2d-button").click();
|
||||
await waitFor(() => canvas.dataset.viewMode === "2d", "return from compact array 3D");
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
|
||||
"pan-y",
|
||||
|
||||
@@ -9,11 +9,26 @@ import {
|
||||
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 },
|
||||
@@ -141,8 +156,8 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
|
||||
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
||||
});
|
||||
|
||||
test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
|
||||
const frontView = {
|
||||
test("Array canvas yaw=0, pitch=0 projection은 -Y축 위치에서 원점을 본다", () => {
|
||||
const negativeYAxisView = {
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
centerPixelX: 200,
|
||||
@@ -151,18 +166,126 @@ test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다
|
||||
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);
|
||||
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 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");
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user