diff --git a/README.md b/README.md
index 0170564..1e3a71d 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,8 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로
수정합니다.
목록에서는 삭제와 채널 순서 변경도 할 수 있습니다.
-5. 3D 배열 검사로 전환해 x/y/z 배치와 **+Z FRONT** 방향을 회전하며 확인합니다.
+5. 3D 배열 검사로 전환해 등각 또는 ±X/±Y/±Z 축 시점을 고르고 x/y/z 배치와
+ **+Z FRONT** 방향을 확인합니다.
6. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 live로 움직이며 XZ·YZ·XY 세 360° polar
응답과 3D 패턴, 조건부 −3.0103 dB 폭 및 sampled peak 동률을 확인합니다.
7. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
@@ -123,7 +124,7 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
-2D/3D 보기와 빔 패턴 주파수·조향 방향·표시 모드도 preview 설정이므로 Array JSON과 undo/dirty
+2D/3D 보기와 3D 축 시점, 빔 패턴 주파수·조향 방향·표시 모드도 preview 설정이므로 Array JSON과 undo/dirty
상태에 들어가지 않습니다. **+Z FRONT**는 좌표계의 전면 표식이며 무지향성 마이크에 별도 지향성을
부여하는 설정이 아닙니다.
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
diff --git a/apps/array-builder/README.md b/apps/array-builder/README.md
index 0f53b2c..19f39f7 100644
--- a/apps/array-builder/README.md
+++ b/apps/array-builder/README.md
@@ -16,7 +16,8 @@ narrowband far-field 수신 빔 패턴을 XZ·YZ·XY 단면과 3D 보기로 확
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
-4. 3D 배열 보기로 전환해 z 배치와 **+Z FRONT** 방향이 의도와 같은지 회전하며 검사합니다.
+4. 3D 배열 보기로 전환해 등각 또는 **±X/±Y/±Z에서 보기**를 선택하고 z 배치와
+ **+Z FRONT** 방향이 의도와 같은지 검사합니다.
5. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 움직이며 세 polar 단면과 3D 패턴의 main
lobe 및 동률 응답을 확인합니다.
6. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
@@ -69,7 +70,10 @@ Array JSON을 저장하세요.
배열 canvas의 2D 모드는 XY 좌표를 배치하는 편집기이고, 3D 모드는 x/y/z 관계와 앞뒤를 회전해 보는
검사기입니다. 좌표 추가·이동·삭제는 2D canvas나 숫자 필드에서 수행합니다. **+Z FRONT** 표시는
양의 z 쪽이 배열의 전면이라는 좌표 기준이며, 이상적 무지향성 마이크에 실제 전후 지향성을 추가하지
-않습니다. 3D에서는 drag 또는 방향키로 회전하고 **R**이나 **화면 맞춤**으로 기본 시점을 복원합니다.
+않습니다. 3D에서는 시점 선택기로 등각 또는 ±X/±Y/±Z 축에서 원점을 정확히 바라볼 수 있습니다.
+여기서 “+X에서 보기”는 카메라가 +X 쪽에 있고 원점을 향한다는 뜻입니다. drag 또는 방향키로
+회전하면 **자유 회전**으로 바뀌고, **R**이나 **화면 맞춤**으로 등각 시점을 복원합니다. 이 카메라
+조작은 마이크 좌표, undo/dirty 상태와 저장 JSON을 바꾸지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
@@ -96,7 +100,9 @@ dB는 10 log10(|B|²)이며 sampled maximum에 맞춰 결과를 다
- **세 polar 단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는
0°=+X·90°=+Y이며 세 plot을 함께 갱신합니다.
- **3D 패턴:** 같은 물리식을 구면 방향으로 평가해 세 단면 사이의 대각 lobe와 앞뒤 모호성을
- 회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다.
+ 회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다. 표시 시점은 등각 또는
+ ±X/±Y/±Z에서 원점을 보는 방향으로 정렬할 수 있고, 시점 변경은 조향 방향·주파수·계산된 구면
+ 응답을 변경하거나 다시 계산하지 않습니다.
- **조향 방향:** 표시된 좌표 규약에 따라 conventional beamformer가 보는 방향입니다.
- **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은
계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다.
diff --git a/apps/array-builder/beam-pattern-surface-view.js b/apps/array-builder/beam-pattern-surface-view.js
index 845da76..4ab9986 100644
--- a/apps/array-builder/beam-pattern-surface-view.js
+++ b/apps/array-builder/beam-pattern-surface-view.js
@@ -1,8 +1,8 @@
+import { orbitForViewDirection } from "./orbit-view.js";
+
const MAX_PIXEL_RATIO = 3;
const MAX_SURFACE_VIEW_SAMPLES = 20_000;
-const MAX_PITCH_RAD = Math.PI * 0.46;
-const DEFAULT_YAW_RAD = -Math.PI / 5;
-const DEFAULT_PITCH_RAD = Math.PI / 4.5;
+const MAX_PITCH_RAD = Math.PI / 2;
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
@@ -114,11 +114,10 @@ function rotatedPoint(point, yaw, pitch) {
function projectedPoint(point, yaw, pitch, size) {
const rotated = rotatedPoint(point, yaw, pitch);
- const perspective = 1 / Math.max(0.68, 1 - rotated[2] * 0.18);
const scale = Math.min(size.width, size.height) * 0.39;
return {
- x: size.width / 2 + rotated[0] * scale * perspective,
- y: size.height / 2 - rotated[1] * scale * perspective,
+ x: size.width / 2 + rotated[0] * scale,
+ y: size.height / 2 - rotated[1] * scale,
depth: rotated[2],
};
}
@@ -133,7 +132,7 @@ function polygonArea(points) {
return Math.abs(area) / 2;
}
-export function createReceiveBeamPatternSurfaceView(canvas) {
+export function createReceiveBeamPatternSurfaceView(canvas, callbacks = {}) {
if (!canvas?.getContext) {
throw new TypeError("3D 빔 패턴 canvas가 필요합니다.");
}
@@ -146,8 +145,8 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
let surface = null;
let validation = null;
let errorMessage = "";
- let yaw = DEFAULT_YAW_RAD;
- let pitch = DEFAULT_PITCH_RAD;
+ let viewDirection = "iso";
+ let { yaw, pitch } = orbitForViewDirection(viewDirection);
let orbit = null;
let frameId = 0;
let destroyed = false;
@@ -324,6 +323,26 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
schedule();
}
+ function updateViewDirection(nextDirection) {
+ if (viewDirection === nextDirection) return;
+ viewDirection = nextDirection;
+ canvas.dataset.viewAxis = viewDirection;
+ callbacks.onViewDirectionChange?.(viewDirection);
+ }
+
+ function setViewDirection(nextDirection) {
+ const nextOrbit = orbitForViewDirection(nextDirection);
+ yaw = nextOrbit.yaw;
+ pitch = nextOrbit.pitch;
+ updateViewDirection(nextDirection);
+ schedule();
+ return viewDirection;
+ }
+
+ function markFreeOrbit() {
+ updateViewDirection("custom");
+ }
+
function finishOrbit(event) {
if (!orbit || (event?.pointerId !== undefined && event.pointerId !== orbit.pointerId)) return;
const pointerId = orbit.pointerId;
@@ -356,12 +375,17 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onPointerMove(event) {
if (!orbit || orbit.pointerId !== event.pointerId) return;
event.preventDefault();
- yaw += (event.clientX - orbit.x) * 0.011;
- pitch = clamp(
- pitch - (event.clientY - orbit.y) * 0.011,
+ const deltaX = event.clientX - orbit.x;
+ const deltaY = event.clientY - orbit.y;
+ const nextYaw = yaw + deltaX * 0.011;
+ const nextPitch = clamp(
+ pitch - deltaY * 0.011,
-MAX_PITCH_RAD,
MAX_PITCH_RAD,
);
+ if (nextYaw !== yaw || nextPitch !== pitch) markFreeOrbit();
+ yaw = nextYaw;
+ pitch = nextPitch;
orbit.x = event.clientX;
orbit.y = event.clientY;
schedule();
@@ -369,16 +393,33 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onKeyDown(event) {
const step = event.shiftKey ? 15 : 5;
- if (event.key === "ArrowLeft") yaw -= step * Math.PI / 180;
- else if (event.key === "ArrowRight") yaw += step * Math.PI / 180;
+ if (event.key === "ArrowLeft") {
+ markFreeOrbit();
+ yaw -= step * Math.PI / 180;
+ }
+ else if (event.key === "ArrowRight") {
+ markFreeOrbit();
+ yaw += step * Math.PI / 180;
+ }
else if (event.key === "ArrowUp") {
- pitch = clamp(pitch + step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
+ const nextPitch = clamp(
+ pitch + step * Math.PI / 180,
+ -MAX_PITCH_RAD,
+ MAX_PITCH_RAD,
+ );
+ if (nextPitch !== pitch) markFreeOrbit();
+ pitch = nextPitch;
} else if (event.key === "ArrowDown") {
- pitch = clamp(pitch - step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
+ const nextPitch = clamp(
+ pitch - step * Math.PI / 180,
+ -MAX_PITCH_RAD,
+ MAX_PITCH_RAD,
+ );
+ if (nextPitch !== pitch) markFreeOrbit();
+ pitch = nextPitch;
}
else if (event.key.toLowerCase() === "r") {
- yaw = DEFAULT_YAW_RAD;
- pitch = DEFAULT_PITCH_RAD;
+ setViewDirection("iso");
} else return;
event.preventDefault();
schedule();
@@ -399,15 +440,15 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
} else {
view.addEventListener?.("resize", onResize);
}
+ canvas.dataset.viewAxis = viewDirection;
schedule();
return {
render,
renderError,
+ setViewDirection,
resetOrbit() {
- yaw = DEFAULT_YAW_RAD;
- pitch = DEFAULT_PITCH_RAD;
- schedule();
+ return setViewDirection("iso");
},
destroy() {
if (destroyed) return;
diff --git a/apps/array-builder/canvas-view.js b/apps/array-builder/canvas-view.js
index 521d3c2..c96e1e0 100644
--- a/apps/array-builder/canvas-view.js
+++ b/apps/array-builder/canvas-view.js
@@ -1,13 +1,14 @@
import { clampPositionToAperture, positionInsideAperture } from "./model.js";
+import { orbitForViewDirection } from "./orbit-view.js";
const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
+const MAX_ORBIT_PITCH = Math.PI / 2;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
-const DEFAULT_ORBIT = Object.freeze({ yaw: -Math.PI / 5, pitch: Math.PI / 4.5 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -704,7 +705,7 @@ function drawMicrophones3d(context, model, viewport) {
return microphones;
}
-function drawArray3d(context, size, model, zoom, orbit) {
+function drawArray3d(context, size, model, zoom, orbit, viewDirection) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
@@ -735,7 +736,13 @@ function drawArray3d(context, size, model, zoom, orbit) {
context.textBaseline = "top";
context.fillStyle = "rgba(205,225,218,.72)";
context.textAlign = "left";
- context.fillText("3D INSPECT - drag to orbit", viewport.plot.x, 8);
+ context.fillText(
+ viewDirection === "custom"
+ ? "3D INSPECT - FREE ORBIT"
+ : "3D INSPECT - VIEW FROM " + viewDirection.toUpperCase(),
+ viewport.plot.x,
+ 8,
+ );
context.textAlign = "right";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
@@ -765,7 +772,8 @@ function normalizedTool(tool) {
* Create the editable microphone-array canvas.
*
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position),
- * onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool), onFit().
+ * onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool),
+ * onViewDirectionChange(direction), onFit().
*/
export function createArrayCanvas(canvas, callbacks = {}) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
@@ -782,7 +790,8 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1;
let tool = "select";
let viewMode = "2d";
- let orbit = { ...DEFAULT_ORBIT };
+ let viewDirection = "iso";
+ let orbit = orbitForViewDirection(viewDirection);
let orbitDrag = null;
let drag = null;
let pendingAdd = null;
@@ -793,6 +802,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
canvas.dataset.viewMode = viewMode;
+ canvas.dataset.viewAxis = viewDirection;
canvas.dataset.orbiting = String(Boolean(orbitDrag));
}
if (canvas.style) {
@@ -807,7 +817,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = viewMode === "3d"
- ? drawArray3d(context, size, model, zoom, orbit)
+ ? drawArray3d(context, size, model, zoom, orbit, viewDirection)
: drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
@@ -853,9 +863,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return tool;
}
+ function updateViewDirection(nextDirection) {
+ if (viewDirection === nextDirection) return;
+ viewDirection = nextDirection;
+ updateCanvasAffordance();
+ callbacks.onViewDirectionChange?.(viewDirection);
+ }
+
+ function setViewDirection(nextDirection) {
+ orbit = orbitForViewDirection(nextDirection);
+ updateViewDirection(nextDirection);
+ viewport = null;
+ schedule();
+ return viewDirection;
+ }
+
+ function markFreeOrbit() {
+ updateViewDirection("custom");
+ }
+
function fit() {
zoom = 1;
- if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
+ if (viewMode === "3d") {
+ orbit = orbitForViewDirection("iso");
+ updateViewDirection("iso");
+ }
viewport = null;
schedule();
return zoom;
@@ -918,17 +950,21 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX;
}
- function finishOrbit(event) {
+ function finishOrbit(event, selectOnClick = false) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
- const pointerId = event?.pointerId ?? orbitDrag.pointerId;
+ const completed = orbitDrag;
+ const pointerId = event?.pointerId ?? completed.pointerId;
orbitDrag = null;
try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch {
// Capture can be released by the browser before cancellation arrives.
}
+ if (selectOnClick && !completed.moved) {
+ callbacks.onSelect?.(completed.hitId);
+ }
updateCanvasAffordance();
schedule();
return true;
@@ -961,10 +997,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
- callbacks.onSelect?.(hit?.id ?? null);
+ const startClient = clientPoint(event);
orbitDrag = {
pointerId: event.pointerId,
- lastClient: clientPoint(event),
+ hitId: hit?.id ?? null,
+ startClient,
+ lastClient: startClient,
+ moved: false,
};
try {
canvas.setPointerCapture?.(event.pointerId);
@@ -1023,11 +1062,26 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onPointerMove(event) {
if (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event);
+ if (!orbitDrag.moved) {
+ const distance = Math.hypot(
+ current.x - orbitDrag.startClient.x,
+ current.y - orbitDrag.startClient.y,
+ );
+ if (distance <= TOUCH_GESTURE_THRESHOLD_PX) return;
+ orbitDrag.moved = true;
+ }
const dx = current.x - orbitDrag.lastClient.x;
const dy = current.y - orbitDrag.lastClient.y;
orbitDrag.lastClient = current;
- orbit.yaw += dx * 0.01;
- orbit.pitch = clamp(orbit.pitch - dy * 0.01, -Math.PI * 0.46, Math.PI * 0.46);
+ const nextYaw = orbit.yaw + dx * 0.01;
+ const nextPitch = clamp(
+ orbit.pitch - dy * 0.01,
+ -MAX_ORBIT_PITCH,
+ MAX_ORBIT_PITCH,
+ );
+ if (nextYaw !== orbit.yaw || nextPitch !== orbit.pitch) markFreeOrbit();
+ orbit.yaw = nextYaw;
+ orbit.pitch = nextPitch;
viewport = null;
event.preventDefault();
schedule();
@@ -1105,6 +1159,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
const orbitStep = event.shiftKey ? 0.18 : 0.06;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
+ markFreeOrbit();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null;
schedule();
@@ -1112,11 +1167,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
- orbit.pitch = clamp(
+ const nextPitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
- -Math.PI * 0.46,
- Math.PI * 0.46,
+ -MAX_ORBIT_PITCH,
+ MAX_ORBIT_PITCH,
);
+ if (nextPitch !== orbit.pitch) markFreeOrbit();
+ orbit.pitch = nextPitch;
viewport = null;
schedule();
}
@@ -1159,7 +1216,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
- if (finishOrbit(event)) return;
+ if (finishOrbit(event, true)) return;
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
@@ -1202,6 +1259,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return {
render,
setTool,
+ setViewDirection,
fit,
zoomBy,
focus,
diff --git a/apps/array-builder/index.html b/apps/array-builder/index.html
index 6b008ad..38c26c7 100644
--- a/apps/array-builder/index.html
+++ b/apps/array-builder/index.html
@@ -58,6 +58,19 @@
+
@@ -190,6 +203,20 @@
+
- 3D 구면 응답drag하여 회전 · +Z FRONT
+ 3D 구면 응답축 시점 선택 또는 drag 회전 · +Z FRONT
3D 표면을 준비하고 있습니다.
diff --git a/apps/array-builder/main.js b/apps/array-builder/main.js
index 07da84c..5951345 100644
--- a/apps/array-builder/main.js
+++ b/apps/array-builder/main.js
@@ -42,6 +42,7 @@ const requiredIds = [
"preset-hint",
"generate-preset-button", "array-canvas", "select-tool-button",
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
+ "array-axis-view-control", "array-axis-view",
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
@@ -52,7 +53,7 @@ const requiredIds = [
"beam-pattern-samples", "beam-pattern-surface-resolution",
"beam-pattern-floor", "beam-pattern-view-cuts-button",
"beam-pattern-view-3d-button", "beam-pattern-cuts-view",
- "beam-pattern-surface-view", "beam-pattern-surface",
+ "beam-pattern-surface-view", "beam-pattern-surface", "beam-pattern-axis-view",
"beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
"beam-pattern-caption-xz", "beam-pattern-caption-yz",
"beam-pattern-caption-xy", "beam-pattern-surface-caption",
@@ -82,6 +83,12 @@ const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
]));
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
elements["beam-pattern-surface"],
+ {
+ onViewDirectionChange(direction) {
+ elements["beam-pattern-axis-view"].value = direction;
+ document.documentElement.dataset.beamPatternViewAxis = direction;
+ },
+ },
);
const splitPane = createSplitPane({
@@ -354,6 +361,29 @@ function announceSelection(message) {
if (elements["selection-status"]) elements["selection-status"].textContent = message;
}
+const VIEW_DIRECTION_LABELS = Object.freeze({
+ iso: "등각",
+ "+x": "+X에서",
+ "-x": "−X에서",
+ "+y": "+Y에서",
+ "-y": "−Y에서",
+ "+z": "+Z FRONT에서",
+ "-z": "−Z BACK에서",
+ custom: "자유 회전",
+});
+
+function syncArrayViewDirection(direction) {
+ const resolved = Object.hasOwn(VIEW_DIRECTION_LABELS, direction) ? direction : "iso";
+ elements["array-axis-view"].value = resolved;
+ elements["array-canvas"].dataset.viewAxis = resolved;
+ document.documentElement.dataset.arrayViewAxis = resolved;
+ if (state.arrayViewMode === "3d") {
+ elements["canvas-front-badge"].textContent = resolved === "custom"
+ ? "+Z FRONT · 자유 회전"
+ : "시점 " + VIEW_DIRECTION_LABELS[resolved] + " 원점 보기 · +Z FRONT";
+ }
+}
+
function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden");
}
@@ -382,6 +412,7 @@ function setArrayViewMode(mode, options = {}) {
elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
+ elements["array-axis-view-control"].hidden = resolved !== "3d";
elements["add-tool-button"].disabled = resolved === "3d";
elements["select-tool-button"].disabled = resolved === "3d";
const instruction = elements["canvas-instructions"];
@@ -403,6 +434,7 @@ function setArrayViewMode(mode, options = {}) {
elements["canvas-front-badge"].textContent = resolved === "3d"
? "+Z FRONT · 초록 축"
: "+Z FRONT · 화면 바깥쪽";
+ if (resolved === "3d") syncArrayViewDirection(elements["array-axis-view"].value);
elements["array-canvas"].setAttribute(
"aria-label",
resolved === "3d"
@@ -567,6 +599,9 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
onToolChange(tool) {
setTool(tool, { userInitiated: true });
},
+ onViewDirectionChange(direction) {
+ syncArrayViewDirection(direction);
+ },
onFit() {
canvasView.fit();
},
@@ -1371,6 +1406,11 @@ elements["array-view-2d-button"].addEventListener("click", () =>
setArrayViewMode("2d", { focus: true }));
elements["array-view-3d-button"].addEventListener("click", () =>
setArrayViewMode("3d", { focus: true }));
+elements["array-axis-view"].addEventListener("change", () => {
+ const direction = elements["array-axis-view"].value;
+ canvasView.setViewDirection(direction);
+ announceSelection("3D 시점 · " + VIEW_DIRECTION_LABELS[direction] + " 원점 보기");
+});
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
@@ -1399,6 +1439,9 @@ elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
setBeamPatternViewMode("cuts"));
elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
setBeamPatternViewMode("surface"));
+elements["beam-pattern-axis-view"].addEventListener("change", () => {
+ beamPatternSurfaceView.setViewDirection(elements["beam-pattern-axis-view"].value);
+});
elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender();
@@ -1615,6 +1658,9 @@ window.addEventListener("unload", () => {
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
+syncArrayViewDirection("iso");
+elements["beam-pattern-axis-view"].value = "iso";
+document.documentElement.dataset.beamPatternViewAxis = "iso";
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
diff --git a/apps/array-builder/orbit-view.js b/apps/array-builder/orbit-view.js
new file mode 100644
index 0000000..85d20e2
--- /dev/null
+++ b/apps/array-builder/orbit-view.js
@@ -0,0 +1,36 @@
+export const ORBIT_VIEW_DIRECTIONS = Object.freeze([
+ "iso",
+ "+x",
+ "-x",
+ "+y",
+ "-y",
+ "+z",
+ "-z",
+]);
+
+const ORBITS = Object.freeze({
+ iso: Object.freeze({
+ yaw: -Math.PI / 4,
+ pitch: Math.asin(1 / Math.sqrt(3)),
+ }),
+ "+x": Object.freeze({ yaw: -Math.PI / 2, pitch: 0 }),
+ "-x": Object.freeze({ yaw: Math.PI / 2, pitch: 0 }),
+ "+y": Object.freeze({ yaw: Math.PI, pitch: 0 }),
+ "-y": Object.freeze({ yaw: 0, pitch: 0 }),
+ "+z": Object.freeze({ yaw: 0, pitch: Math.PI / 2 }),
+ "-z": Object.freeze({ yaw: Math.PI, pitch: -Math.PI / 2 }),
+});
+
+/**
+ * Return the orthographic camera orbit for a view from the named axis toward
+ * the array origin. A fresh object prevents one view from mutating another.
+ */
+export function orbitForViewDirection(direction = "iso") {
+ const orbit = ORBITS[direction];
+ if (!orbit) {
+ throw new RangeError(
+ "view direction must be one of: " + ORBIT_VIEW_DIRECTIONS.join(", "),
+ );
+ }
+ return { ...orbit };
+}
diff --git a/apps/array-builder/styles.css b/apps/array-builder/styles.css
index a2fe2bb..fa15910 100644
--- a/apps/array-builder/styles.css
+++ b/apps/array-builder/styles.css
@@ -1030,6 +1030,45 @@ details > summary::-webkit-details-marker {
border-right: 1px solid var(--line);
}
+.axis-view-control {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: auto minmax(132px, 1fr);
+ align-items: center;
+ gap: 7px;
+ padding-right: 8px;
+ border-right: 1px solid var(--line);
+}
+
+.axis-view-control[hidden] {
+ display: none !important;
+}
+
+.axis-view-control > span {
+ color: var(--muted);
+ font-size: 0.64rem;
+ font-weight: 680;
+ white-space: nowrap;
+}
+
+.axis-view-control select {
+ width: 100%;
+ min-width: 0;
+ max-width: 184px;
+ min-height: 32px;
+ padding: 5px 27px 5px 8px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ color: var(--text);
+ background: #0b1714;
+ font-size: 0.66rem;
+}
+
+.axis-view-control select:focus-visible {
+ border-color: var(--mint);
+ outline: 2px solid rgb(101 242 191 / 10%);
+}
+
.tool-button,
.icon-button {
min-height: 32px;
@@ -1429,6 +1468,11 @@ body[data-array-tool="add"] #array-canvas,
touch-action: pan-y;
}
+ #array-canvas[data-view-mode="3d"],
+ #beam-pattern-surface {
+ touch-action: pan-y;
+ }
+
.shortcut-help > div {
grid-template-columns: minmax(0, 1fr);
}
@@ -1488,6 +1532,17 @@ body[data-array-tool="add"] #array-canvas,
flex: 1 1 auto;
}
+ .axis-view-control {
+ width: 100%;
+ grid-template-columns: auto minmax(0, 1fr);
+ padding-right: 0;
+ border-right: 0;
+ }
+
+ .axis-view-control select {
+ max-width: none;
+ }
+
.zoom-tools {
width: 100%;
}
@@ -1504,6 +1559,15 @@ body[data-array-tool="add"] #array-canvas,
height: 430px;
}
+ .canvas-front-badge {
+ bottom: 48px;
+ max-width: calc(100% - 24px);
+ border-radius: 10px;
+ line-height: 1.35;
+ text-overflow: clip;
+ white-space: normal;
+ }
+
.array-statistics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
diff --git a/docs/beam-pattern.md b/docs/beam-pattern.md
index 85980e0..337d8ae 100644
--- a/docs/beam-pattern.md
+++ b/docs/beam-pattern.md
@@ -66,6 +66,11 @@ delay-and-sum weight입니다. 따라서 같은 0 dB 응답이 여러 방향에
아닙니다. 3D 패턴은 같은 좌표계의 단위구면 방향 u들을 평가하므로 polar 세 단면에 없는
대각 방향 응답도 드러냅니다.
+배열 3D와 패턴 3D의 시점 선택은 “선택한 축 쪽에서 원점을 본다”는 규칙을 공유합니다. 예를 들어
+**+Z FRONT에서 보기**는 카메라가 +Z에 있고 원점을 향하며, 화면 오른쪽은 +X, 위는 +Y입니다.
+시점 선택과 자유 회전은 이미 계산된 좌표와 표면을 투영하는 카메라 동작일 뿐이므로 배열 좌표,
+조향 방향 u0, 주파수와 raw 응답을 바꾸거나 빔 패턴을 다시 계산하지 않습니다.
+
3D 표면은 구면 격자의 각 표시용 displayDb 표본을
rho=clamp((displayDb-dbFloor)/(0-dbFloor),0,1) 반지름으로 놓고 인접 격자점을 면으로
연결합니다. 이는 dB 동적 범위를 읽기 위한 표시 좌표이며 raw power를 바꾸지 않습니다. 격자 사이의
diff --git a/docs/index.html b/docs/index.html
index 3ace193..7dedbed 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -102,7 +102,8 @@
Array Builder에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.
격자·선형·원형 링·Archimedean 나선·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.
canvas의 추가 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.
- canvas 아래 수신 빔 패턴에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.
+ 3D 검사에서 등각 또는 ±X/±Y/±Z 축에서 원점을 보는 시점으로 배치와 +Z FRONT를 확인합니다.
+ canvas 아래 수신 빔 패턴에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인하고, 3D 패턴도 같은 축 시점으로 정렬합니다.
목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 Array JSON 저장 가능이면 저장합니다.
Acoustic Camera의 Array 입력에서 저장한 파일을 선택합니다.
diff --git a/tests/integration/array-builder-ui.test.js b/tests/integration/array-builder-ui.test.js
index cb70116..0c3882a 100644
--- a/tests/integration/array-builder-ui.test.js
+++ b/tests/integration/array-builder-ui.test.js
@@ -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",
diff --git a/tests/unit/array-builder-canvas.test.js b/tests/unit/array-builder-canvas.test.js
index 40125d2..2a2573b 100644
--- a/tests/unit/array-builder-canvas.test.js
+++ b/tests/unit/array-builder-canvas.test.js
@@ -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");
});