feat: add axis-aligned 3d camera views

This commit is contained in:
2026-08-30 11:40:21 +09:00
parent 51c7bbd859
commit 2a2e192196
12 changed files with 619 additions and 61 deletions
+3 -2
View File
@@ -112,7 +112,8 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로 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 6. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 live로 움직이며 XZ·YZ·XY 세 360° polar
응답과 3D 패턴, 조건부 −3.0103 dB 폭 및 sampled peak 동률을 확인합니다. 응답과 3D 패턴, 조건부 −3.0103 dB 폭 및 sampled peak 동률을 확인합니다.
7. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다. 7. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
@@ -123,7 +124,7 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에 생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다. 저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
2D/3D 보기와 빔 패턴 주파수·조향 방향·표시 모드도 preview 설정이므로 Array JSON과 undo/dirty 2D/3D 보기와 3D 축 시점, 빔 패턴 주파수·조향 방향·표시 모드도 preview 설정이므로 Array JSON과 undo/dirty
상태에 들어가지 않습니다. **+Z FRONT**는 좌표계의 전면 표식이며 무지향성 마이크에 별도 지향성을 상태에 들어가지 않습니다. **+Z FRONT**는 좌표계의 전면 표식이며 무지향성 마이크에 별도 지향성을
부여하는 설정이 아닙니다. 부여하는 설정이 아닙니다.
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요. 자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
+9 -3
View File
@@ -16,7 +16,8 @@ narrowband far-field 수신 빔 패턴을 XZ·YZ·XY 단면과 3D 보기로 확
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다. 1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다. 2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다. 3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
4. 3D 배열 보기로 전환해 z 배치와 **+Z FRONT** 방향이 의도와 같은지 회전하며 검사합니다. 4. 3D 배열 보기로 전환해 등각 또는 **±X/±Y/±Z에서 보기**를 선택하고 z 배치와
**+Z FRONT** 방향이 의도와 같은지 검사합니다.
5. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 움직이며 세 polar 단면과 3D 패턴의 main 5. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 움직이며 세 polar 단면과 3D 패턴의 main
lobe 및 동률 응답을 확인합니다. lobe 및 동률 응답을 확인합니다.
6. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다. 6. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
@@ -69,7 +70,10 @@ Array JSON을 저장하세요.
배열 canvas의 2D 모드는 XY 좌표를 배치하는 편집기이고, 3D 모드는 x/y/z 관계와 앞뒤를 회전해 보는 배열 canvas의 2D 모드는 XY 좌표를 배치하는 편집기이고, 3D 모드는 x/y/z 관계와 앞뒤를 회전해 보는
검사기입니다. 좌표 추가·이동·삭제는 2D canvas나 숫자 필드에서 수행합니다. **+Z FRONT** 표시는 검사기입니다. 좌표 추가·이동·삭제는 2D canvas나 숫자 필드에서 수행합니다. **+Z FRONT** 표시는
양의 z 쪽이 배열의 전면이라는 좌표 기준이며, 이상적 무지향성 마이크에 실제 전후 지향성을 추가하지 양의 z 쪽이 배열의 전면이라는 좌표 기준이며, 이상적 무지향성 마이크에 실제 전후 지향성을 추가하지
않습니다. 3D에서는 drag 또는 방향키로 회전하고 **R**이나 **화면 맞춤**으로 기본 시점을 복원합니다. 않습니다. 3D에서는 시점 선택기로 등각 또는 ±X/±Y/±Z 축에서 원점을 정확히 바라볼 수 있습니다.
여기서 “+X에서 보기”는 카메라가 +X 쪽에 있고 원점을 향한다는 뜻입니다. drag 또는 방향키로
회전하면 **자유 회전**으로 바뀌고, **R**이나 **화면 맞춤**으로 등각 시점을 복원합니다. 이 카메라
조작은 마이크 좌표, undo/dirty 상태와 저장 JSON을 바꾸지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로 터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로 스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
@@ -96,7 +100,9 @@ dB는 <code>10 log10(|B|²)</code>이며 sampled maximum에 맞춰 결과를 다
- **세 polar 단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는 - **세 polar 단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는
0°=+X·90°=+Y이며 세 plot을 함께 갱신합니다. 0°=+X·90°=+Y이며 세 plot을 함께 갱신합니다.
- **3D 패턴:** 같은 물리식을 구면 방향으로 평가해 세 단면 사이의 대각 lobe와 앞뒤 모호성을 - **3D 패턴:** 같은 물리식을 구면 방향으로 평가해 세 단면 사이의 대각 lobe와 앞뒤 모호성을
회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다. 회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다. 표시 시점은 등각 또는
±X/±Y/±Z에서 원점을 보는 방향으로 정렬할 수 있고, 시점 변경은 조향 방향·주파수·계산된 구면
응답을 변경하거나 다시 계산하지 않습니다.
- **조향 방향:** 표시된 좌표 규약에 따라 conventional beamformer가 보는 방향입니다. - **조향 방향:** 표시된 좌표 규약에 따라 conventional beamformer가 보는 방향입니다.
- **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은 - **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은
계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다. 계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다.
+62 -21
View File
@@ -1,8 +1,8 @@
import { orbitForViewDirection } from "./orbit-view.js";
const MAX_PIXEL_RATIO = 3; const MAX_PIXEL_RATIO = 3;
const MAX_SURFACE_VIEW_SAMPLES = 20_000; const MAX_SURFACE_VIEW_SAMPLES = 20_000;
const MAX_PITCH_RAD = Math.PI * 0.46; const MAX_PITCH_RAD = Math.PI / 2;
const DEFAULT_YAW_RAD = -Math.PI / 5;
const DEFAULT_PITCH_RAD = Math.PI / 4.5;
function clamp(value, minimum, maximum) { function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value)); return Math.min(maximum, Math.max(minimum, value));
@@ -114,11 +114,10 @@ function rotatedPoint(point, yaw, pitch) {
function projectedPoint(point, yaw, pitch, size) { function projectedPoint(point, yaw, pitch, size) {
const rotated = rotatedPoint(point, yaw, pitch); 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; const scale = Math.min(size.width, size.height) * 0.39;
return { return {
x: size.width / 2 + rotated[0] * scale * perspective, x: size.width / 2 + rotated[0] * scale,
y: size.height / 2 - rotated[1] * scale * perspective, y: size.height / 2 - rotated[1] * scale,
depth: rotated[2], depth: rotated[2],
}; };
} }
@@ -133,7 +132,7 @@ function polygonArea(points) {
return Math.abs(area) / 2; return Math.abs(area) / 2;
} }
export function createReceiveBeamPatternSurfaceView(canvas) { export function createReceiveBeamPatternSurfaceView(canvas, callbacks = {}) {
if (!canvas?.getContext) { if (!canvas?.getContext) {
throw new TypeError("3D 빔 패턴 canvas가 필요합니다."); throw new TypeError("3D 빔 패턴 canvas가 필요합니다.");
} }
@@ -146,8 +145,8 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
let surface = null; let surface = null;
let validation = null; let validation = null;
let errorMessage = ""; let errorMessage = "";
let yaw = DEFAULT_YAW_RAD; let viewDirection = "iso";
let pitch = DEFAULT_PITCH_RAD; let { yaw, pitch } = orbitForViewDirection(viewDirection);
let orbit = null; let orbit = null;
let frameId = 0; let frameId = 0;
let destroyed = false; let destroyed = false;
@@ -324,6 +323,26 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
schedule(); 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) { function finishOrbit(event) {
if (!orbit || (event?.pointerId !== undefined && event.pointerId !== orbit.pointerId)) return; if (!orbit || (event?.pointerId !== undefined && event.pointerId !== orbit.pointerId)) return;
const pointerId = orbit.pointerId; const pointerId = orbit.pointerId;
@@ -356,12 +375,17 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onPointerMove(event) { function onPointerMove(event) {
if (!orbit || orbit.pointerId !== event.pointerId) return; if (!orbit || orbit.pointerId !== event.pointerId) return;
event.preventDefault(); event.preventDefault();
yaw += (event.clientX - orbit.x) * 0.011; const deltaX = event.clientX - orbit.x;
pitch = clamp( const deltaY = event.clientY - orbit.y;
pitch - (event.clientY - orbit.y) * 0.011, const nextYaw = yaw + deltaX * 0.011;
const nextPitch = clamp(
pitch - deltaY * 0.011,
-MAX_PITCH_RAD, -MAX_PITCH_RAD,
MAX_PITCH_RAD, MAX_PITCH_RAD,
); );
if (nextYaw !== yaw || nextPitch !== pitch) markFreeOrbit();
yaw = nextYaw;
pitch = nextPitch;
orbit.x = event.clientX; orbit.x = event.clientX;
orbit.y = event.clientY; orbit.y = event.clientY;
schedule(); schedule();
@@ -369,16 +393,33 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onKeyDown(event) { function onKeyDown(event) {
const step = event.shiftKey ? 15 : 5; const step = event.shiftKey ? 15 : 5;
if (event.key === "ArrowLeft") yaw -= step * Math.PI / 180; if (event.key === "ArrowLeft") {
else if (event.key === "ArrowRight") yaw += step * Math.PI / 180; markFreeOrbit();
yaw -= step * Math.PI / 180;
}
else if (event.key === "ArrowRight") {
markFreeOrbit();
yaw += step * Math.PI / 180;
}
else if (event.key === "ArrowUp") { 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") { } 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") { else if (event.key.toLowerCase() === "r") {
yaw = DEFAULT_YAW_RAD; setViewDirection("iso");
pitch = DEFAULT_PITCH_RAD;
} else return; } else return;
event.preventDefault(); event.preventDefault();
schedule(); schedule();
@@ -399,15 +440,15 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
} else { } else {
view.addEventListener?.("resize", onResize); view.addEventListener?.("resize", onResize);
} }
canvas.dataset.viewAxis = viewDirection;
schedule(); schedule();
return { return {
render, render,
renderError, renderError,
setViewDirection,
resetOrbit() { resetOrbit() {
yaw = DEFAULT_YAW_RAD; return setViewDirection("iso");
pitch = DEFAULT_PITCH_RAD;
schedule();
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
+75 -17
View File
@@ -1,13 +1,14 @@
import { clampPositionToAperture, positionInsideAperture } from "./model.js"; import { clampPositionToAperture, positionInsideAperture } from "./model.js";
import { orbitForViewDirection } from "./orbit-view.js";
const BACKGROUND = "#07110f"; const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714"; const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3; const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25; const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16; const MAX_ZOOM = 16;
const MAX_ORBIT_PITCH = Math.PI / 2;
const TOUCH_GESTURE_THRESHOLD_PX = 6; const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 }); 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"]); export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -704,7 +705,7 @@ function drawMicrophones3d(context, model, viewport) {
return microphones; return microphones;
} }
function drawArray3d(context, size, model, zoom, orbit) { function drawArray3d(context, size, model, zoom, orbit, viewDirection) {
context.fillStyle = BACKGROUND; context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height); context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) { if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
@@ -735,7 +736,13 @@ function drawArray3d(context, size, model, zoom, orbit) {
context.textBaseline = "top"; context.textBaseline = "top";
context.fillStyle = "rgba(205,225,218,.72)"; context.fillStyle = "rgba(205,225,218,.72)";
context.textAlign = "left"; 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.textAlign = "right";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8); context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left"; context.textAlign = "left";
@@ -765,7 +772,8 @@ function normalizedTool(tool) {
* Create the editable microphone-array canvas. * Create the editable microphone-array canvas.
* *
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position), * 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 = {}) { export function createArrayCanvas(canvas, callbacks = {}) {
if (!canvas) throw new TypeError("array canvas가 필요합니다."); if (!canvas) throw new TypeError("array canvas가 필요합니다.");
@@ -782,7 +790,8 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1; let zoom = 1;
let tool = "select"; let tool = "select";
let viewMode = "2d"; let viewMode = "2d";
let orbit = { ...DEFAULT_ORBIT }; let viewDirection = "iso";
let orbit = orbitForViewDirection(viewDirection);
let orbitDrag = null; let orbitDrag = null;
let drag = null; let drag = null;
let pendingAdd = null; let pendingAdd = null;
@@ -793,6 +802,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.dataset.tool = tool; canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom); canvas.dataset.zoom = String(zoom);
canvas.dataset.viewMode = viewMode; canvas.dataset.viewMode = viewMode;
canvas.dataset.viewAxis = viewDirection;
canvas.dataset.orbiting = String(Boolean(orbitDrag)); canvas.dataset.orbiting = String(Boolean(orbitDrag));
} }
if (canvas.style) { if (canvas.style) {
@@ -807,7 +817,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (destroyed) return; if (destroyed) return;
const size = prepareContext(canvas, context, view); const size = prepareContext(canvas, context, view);
viewport = viewMode === "3d" viewport = viewMode === "3d"
? drawArray3d(context, size, model, zoom, orbit) ? drawArray3d(context, size, model, zoom, orbit, viewDirection)
: drawArray(context, size, model, zoom, drag); : drawArray(context, size, model, zoom, drag);
updateCanvasAffordance(); updateCanvasAffordance();
} }
@@ -853,9 +863,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return tool; 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() { function fit() {
zoom = 1; zoom = 1;
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT }; if (viewMode === "3d") {
orbit = orbitForViewDirection("iso");
updateViewDirection("iso");
}
viewport = null; viewport = null;
schedule(); schedule();
return zoom; return zoom;
@@ -918,17 +950,21 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX; TOUCH_GESTURE_THRESHOLD_PX;
} }
function finishOrbit(event) { function finishOrbit(event, selectOnClick = false) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) { if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false; return false;
} }
const pointerId = event?.pointerId ?? orbitDrag.pointerId; const completed = orbitDrag;
const pointerId = event?.pointerId ?? completed.pointerId;
orbitDrag = null; orbitDrag = null;
try { try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId); if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch { } catch {
// Capture can be released by the browser before cancellation arrives. // Capture can be released by the browser before cancellation arrives.
} }
if (selectOnClick && !completed.moved) {
callbacks.onSelect?.(completed.hitId);
}
updateCanvasAffordance(); updateCanvasAffordance();
schedule(); schedule();
return true; return true;
@@ -961,10 +997,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
const currentViewport = ensureViewport(); const currentViewport = ensureViewport();
if (!currentViewport) return; if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport); const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
callbacks.onSelect?.(hit?.id ?? null); const startClient = clientPoint(event);
orbitDrag = { orbitDrag = {
pointerId: event.pointerId, pointerId: event.pointerId,
lastClient: clientPoint(event), hitId: hit?.id ?? null,
startClient,
lastClient: startClient,
moved: false,
}; };
try { try {
canvas.setPointerCapture?.(event.pointerId); canvas.setPointerCapture?.(event.pointerId);
@@ -1023,11 +1062,26 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onPointerMove(event) { function onPointerMove(event) {
if (orbitDrag?.pointerId === event.pointerId) { if (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event); 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 dx = current.x - orbitDrag.lastClient.x;
const dy = current.y - orbitDrag.lastClient.y; const dy = current.y - orbitDrag.lastClient.y;
orbitDrag.lastClient = current; orbitDrag.lastClient = current;
orbit.yaw += dx * 0.01; const nextYaw = orbit.yaw + dx * 0.01;
orbit.pitch = clamp(orbit.pitch - dy * 0.01, -Math.PI * 0.46, Math.PI * 0.46); 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; viewport = null;
event.preventDefault(); event.preventDefault();
schedule(); schedule();
@@ -1105,6 +1159,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
const orbitStep = event.shiftKey ? 0.18 : 0.06; const orbitStep = event.shiftKey ? 0.18 : 0.06;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") { if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault(); event.preventDefault();
markFreeOrbit();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep; orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null; viewport = null;
schedule(); schedule();
@@ -1112,11 +1167,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
} }
if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
orbit.pitch = clamp( const nextPitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep), orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-Math.PI * 0.46, -MAX_ORBIT_PITCH,
Math.PI * 0.46, MAX_ORBIT_PITCH,
); );
if (nextPitch !== orbit.pitch) markFreeOrbit();
orbit.pitch = nextPitch;
viewport = null; viewport = null;
schedule(); schedule();
} }
@@ -1159,7 +1216,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
} }
function onPointerUp(event) { function onPointerUp(event) {
if (finishOrbit(event)) return; if (finishOrbit(event, true)) return;
if (finishPendingAdd(event, false)) return; if (finishPendingAdd(event, false)) return;
finishDrag(event, false); finishDrag(event, false);
} }
@@ -1202,6 +1259,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return { return {
render, render,
setTool, setTool,
setViewDirection,
fit, fit,
zoomBy, zoomBy,
focus, focus,
+28 -1
View File
@@ -58,6 +58,19 @@
<button class="tool-button is-active" id="array-view-2d-button" type="button" aria-pressed="true">2D 편집</button> <button class="tool-button is-active" id="array-view-2d-button" type="button" aria-pressed="true">2D 편집</button>
<button class="tool-button" id="array-view-3d-button" type="button" aria-pressed="false">3D 검사</button> <button class="tool-button" id="array-view-3d-button" type="button" aria-pressed="false">3D 검사</button>
</div> </div>
<label class="axis-view-control" id="array-axis-view-control" hidden>
<span>3D 시점</span>
<select id="array-axis-view" aria-label="배열 3D 시점, 선택한 축에서 원점 보기">
<option value="iso">등각</option>
<option value="+x">+X에서 보기</option>
<option value="-x">−X에서 보기</option>
<option value="+y">+Y에서 보기</option>
<option value="-y">−Y에서 보기</option>
<option value="+z">+Z FRONT에서 보기</option>
<option value="-z">−Z BACK에서 보기</option>
<option value="custom" disabled>자유 회전</option>
</select>
</label>
<div class="toolbar-group" aria-label="실행 취소와 다시 실행"> <div class="toolbar-group" aria-label="실행 취소와 다시 실행">
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button> <button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button> <button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
@@ -190,6 +203,20 @@
<option value="2.5">2.5° · 144×73</option> <option value="2.5">2.5° · 144×73</option>
</select> </select>
</label> </label>
<label class="field" data-beam-mode="surface" hidden>
<span>3D 표시 시점</span>
<select id="beam-pattern-axis-view" aria-label="빔 패턴 3D 시점, 선택한 축에서 원점 보기">
<option value="iso">등각</option>
<option value="+x">+X에서 보기</option>
<option value="-x">−X에서 보기</option>
<option value="+y">+Y에서 보기</option>
<option value="-y">−Y에서 보기</option>
<option value="+z">+Z FRONT에서 보기</option>
<option value="-z">−Z BACK에서 보기</option>
<option value="custom" disabled>자유 회전</option>
</select>
<small>카메라만 변경 · 조향 방향과 응답 계산은 유지</small>
</label>
<label class="field"> <label class="field">
<span>표시 하한 (dB)</span> <span>표시 하한 (dB)</span>
<select id="beam-pattern-floor"> <select id="beam-pattern-floor">
@@ -240,7 +267,7 @@
</div> </div>
<figure class="beam-pattern-surface-figure" id="beam-pattern-surface-view" hidden> <figure class="beam-pattern-surface-figure" id="beam-pattern-surface-view" hidden>
<div class="beam-pattern-figure-heading"><strong>3D 구면 응답</strong><span>drag하여 회전 · +Z FRONT</span></div> <div class="beam-pattern-figure-heading"><strong>3D 구면 응답</strong><span>축 시점 선택 또는 drag 회전 · +Z FRONT</span></div>
<canvas id="beam-pattern-surface" tabindex="0" role="img" aria-label="3D 수신 빔 패턴 표면" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-surface-caption"></canvas> <canvas id="beam-pattern-surface" tabindex="0" role="img" aria-label="3D 수신 빔 패턴 표면" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-surface-caption"></canvas>
<figcaption id="beam-pattern-surface-caption">3D 표면을 준비하고 있습니다.</figcaption> <figcaption id="beam-pattern-surface-caption">3D 표면을 준비하고 있습니다.</figcaption>
</figure> </figure>
+47 -1
View File
@@ -42,6 +42,7 @@ const requiredIds = [
"preset-hint", "preset-hint",
"generate-preset-button", "array-canvas", "select-tool-button", "generate-preset-button", "array-canvas", "select-tool-button",
"add-tool-button", "array-view-2d-button", "array-view-3d-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", "canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
"snap-enabled", "snap-step", "microphone-list", "microphone-id", "snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button", "microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
@@ -52,7 +53,7 @@ const requiredIds = [
"beam-pattern-samples", "beam-pattern-surface-resolution", "beam-pattern-samples", "beam-pattern-surface-resolution",
"beam-pattern-floor", "beam-pattern-view-cuts-button", "beam-pattern-floor", "beam-pattern-view-cuts-button",
"beam-pattern-view-3d-button", "beam-pattern-cuts-view", "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-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
"beam-pattern-caption-xz", "beam-pattern-caption-yz", "beam-pattern-caption-xz", "beam-pattern-caption-yz",
"beam-pattern-caption-xy", "beam-pattern-surface-caption", "beam-pattern-caption-xy", "beam-pattern-surface-caption",
@@ -82,6 +83,12 @@ const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
])); ]));
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView( const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
elements["beam-pattern-surface"], elements["beam-pattern-surface"],
{
onViewDirectionChange(direction) {
elements["beam-pattern-axis-view"].value = direction;
document.documentElement.dataset.beamPatternViewAxis = direction;
},
},
); );
const splitPane = createSplitPane({ const splitPane = createSplitPane({
@@ -354,6 +361,29 @@ function announceSelection(message) {
if (elements["selection-status"]) elements["selection-status"].textContent = 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() { function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden"); 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-3d-button"].classList.toggle("is-active", resolved === "3d");
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d")); elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d")); 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["add-tool-button"].disabled = resolved === "3d";
elements["select-tool-button"].disabled = resolved === "3d"; elements["select-tool-button"].disabled = resolved === "3d";
const instruction = elements["canvas-instructions"]; const instruction = elements["canvas-instructions"];
@@ -403,6 +434,7 @@ function setArrayViewMode(mode, options = {}) {
elements["canvas-front-badge"].textContent = resolved === "3d" elements["canvas-front-badge"].textContent = resolved === "3d"
? "+Z FRONT · 초록 축" ? "+Z FRONT · 초록 축"
: "+Z FRONT · 화면 바깥쪽"; : "+Z FRONT · 화면 바깥쪽";
if (resolved === "3d") syncArrayViewDirection(elements["array-axis-view"].value);
elements["array-canvas"].setAttribute( elements["array-canvas"].setAttribute(
"aria-label", "aria-label",
resolved === "3d" resolved === "3d"
@@ -567,6 +599,9 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
onToolChange(tool) { onToolChange(tool) {
setTool(tool, { userInitiated: true }); setTool(tool, { userInitiated: true });
}, },
onViewDirectionChange(direction) {
syncArrayViewDirection(direction);
},
onFit() { onFit() {
canvasView.fit(); canvasView.fit();
}, },
@@ -1371,6 +1406,11 @@ elements["array-view-2d-button"].addEventListener("click", () =>
setArrayViewMode("2d", { focus: true })); setArrayViewMode("2d", { focus: true }));
elements["array-view-3d-button"].addEventListener("click", () => elements["array-view-3d-button"].addEventListener("click", () =>
setArrayViewMode("3d", { focus: true })); 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["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25)); elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8)); elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
@@ -1399,6 +1439,9 @@ elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
setBeamPatternViewMode("cuts")); setBeamPatternViewMode("cuts"));
elements["beam-pattern-view-3d-button"].addEventListener("click", () => elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
setBeamPatternViewMode("surface")); setBeamPatternViewMode("surface"));
elements["beam-pattern-axis-view"].addEventListener("change", () => {
beamPatternSurfaceView.setViewDirection(elements["beam-pattern-axis-view"].value);
});
elements["beam-pattern-panel"].addEventListener("toggle", () => { elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) { if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender(); scheduleBeamPatternRender();
@@ -1615,6 +1658,9 @@ window.addEventListener("unload", () => {
elements["snap-enabled"].checked = state.snapEnabled; elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000); 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 }); setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false }); setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency(); syncBeamPatternFrequency();
+36
View File
@@ -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 };
}
+64
View File
@@ -1030,6 +1030,45 @@ details > summary::-webkit-details-marker {
border-right: 1px solid var(--line); 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, .tool-button,
.icon-button { .icon-button {
min-height: 32px; min-height: 32px;
@@ -1429,6 +1468,11 @@ body[data-array-tool="add"] #array-canvas,
touch-action: pan-y; touch-action: pan-y;
} }
#array-canvas[data-view-mode="3d"],
#beam-pattern-surface {
touch-action: pan-y;
}
.shortcut-help > div { .shortcut-help > div {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -1488,6 +1532,17 @@ body[data-array-tool="add"] #array-canvas,
flex: 1 1 auto; 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 { .zoom-tools {
width: 100%; width: 100%;
} }
@@ -1504,6 +1559,15 @@ body[data-array-tool="add"] #array-canvas,
height: 430px; 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 { .array-statistics {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
+5
View File
@@ -66,6 +66,11 @@ delay-and-sum weight입니다. 따라서 같은 0 dB 응답이 여러 방향에
아닙니다. 3D 패턴은 같은 좌표계의 단위구면 방향 <code>u</code>들을 평가하므로 polar 세 단면에 없는 아닙니다. 3D 패턴은 같은 좌표계의 단위구면 방향 <code>u</code>들을 평가하므로 polar 세 단면에 없는
대각 방향 응답도 드러냅니다. 대각 방향 응답도 드러냅니다.
배열 3D와 패턴 3D의 시점 선택은 “선택한 축 쪽에서 원점을 본다”는 규칙을 공유합니다. 예를 들어
**+Z FRONT에서 보기**는 카메라가 +Z에 있고 원점을 향하며, 화면 오른쪽은 +X, 위는 +Y입니다.
시점 선택과 자유 회전은 이미 계산된 좌표와 표면을 투영하는 카메라 동작일 뿐이므로 배열 좌표,
조향 방향 <code>u0</code>, 주파수와 raw 응답을 바꾸거나 빔 패턴을 다시 계산하지 않습니다.
3D 표면은 구면 격자의 각 표시용 <code>displayDb</code> 표본을 3D 표면은 구면 격자의 각 표시용 <code>displayDb</code> 표본을
<code>rho=clamp((displayDb-dbFloor)/(0-dbFloor),0,1)</code> 반지름으로 놓고 인접 격자점을 면으로 <code>rho=clamp((displayDb-dbFloor)/(0-dbFloor),0,1)</code> 반지름으로 놓고 인접 격자점을 면으로
연결합니다. 이는 dB 동적 범위를 읽기 위한 표시 좌표이며 raw power를 바꾸지 않습니다. 격자 사이의 연결합니다. 이는 dB 동적 범위를 읽기 위한 표시 좌표이며 raw power를 바꾸지 않습니다. 격자 사이의
+2 -1
View File
@@ -102,7 +102,8 @@
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li> <li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
<li>격자·선형·원형 링·Archimedean 나선·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li> <li>격자·선형·원형 링·Archimedean 나선·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
<li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li> <li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.</li> <li>3D 검사에서 등각 또는 ±X/±Y/±Z 축에서 원점을 보는 시점으로 배치와 +Z FRONT를 확인합니다.</li>
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인하고, 3D 패턴도 같은 축 시점으로 정렬합니다.</li>
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li> <li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li> <li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
</ol> </ol>
+152 -2
View File
@@ -279,6 +279,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
"3D beam caption discloses the display-only radial mapping", "3D beam caption discloses the display-only radial mapping",
); );
await nextPaint(windowObject); 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 surfaceBeforeOrbit = beamSurface.toDataURL();
const beamSurfaceRect = beamSurface.getBoundingClientRect(); const beamSurfaceRect = beamSurface.getBoundingClientRect();
const beamOrbitPointerId = 71; const beamOrbitPointerId = 71;
@@ -317,6 +355,21 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
beamSurface.toDataURL() !== surfaceBeforeOrbit, beamSurface.toDataURL() !== surfaceBeforeOrbit,
"3D beam surface bitmap changes after orbit rotation", "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(); documentObject.querySelector("#beam-pattern-view-cuts-button").click();
await waitFor( await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "cuts" && () => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
@@ -364,9 +417,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
); );
assert( assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes( documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 초록 축", "시점 등각",
), ),
"3D canvas identifies the +Z front axis", "3D canvas identifies its isometric camera",
); );
assert( assert(
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"), documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
@@ -389,6 +442,41 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
coordinatesBeforeArray3d.join(","), coordinatesBeforeArray3d.join(","),
"3D inspection leaves microphone coordinates unchanged", "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 array3dBeforeOrbit = canvas.toDataURL();
const array3dRect = canvas.getBoundingClientRect(); const array3dRect = canvas.getBoundingClientRect();
const arrayOrbitPointerId = 72; const arrayOrbitPointerId = 72;
@@ -427,12 +515,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
canvas.toDataURL() !== array3dBeforeOrbit, canvas.toDataURL() !== array3dBeforeOrbit,
"array 3D canvas bitmap changes after orbit rotation", "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(); documentObject.querySelector("#array-view-2d-button").click();
await waitFor( await waitFor(
() => canvas.dataset.viewMode === "2d" && () => canvas.dataset.viewMode === "2d" &&
canvas.getAttribute("aria-label").includes("평면 편집"), canvas.getAttribute("aria-label").includes("평면 편집"),
"return to array 2D editing", "return to array 2D editing",
); );
assert(arrayAxisControl.hidden, "array axis selector hides in 2D editing");
assert( assert(
!documentObject.querySelector("#add-tool-button").disabled && !documentObject.querySelector("#add-tool-button").disabled &&
!documentObject.querySelector("#select-tool-button").disabled, !documentObject.querySelector("#select-tool-button").disabled,
@@ -1138,6 +1248,18 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
0.03, 0.03,
"compact 3D beam surface preserves its aspect ratio", "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( assert(
documentObject.documentElement.scrollWidth <= documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1, documentObject.documentElement.clientWidth + 1,
@@ -1147,6 +1269,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px", "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( assertEqual(
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction, windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
"pan-y", "pan-y",
+136 -13
View File
@@ -9,11 +9,26 @@ import {
niceGridStep, niceGridStep,
projectArrayPosition3d, projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js"; } 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) { function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual); 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() { function fixture() {
return { return {
aperture: { width: 0.4, height: 0.2 }, 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"); assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
}); });
test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => { test("Array canvas yaw=0, pitch=0 projection은 -Y축 위치에서 원점을 본다", () => {
const frontView = { const negativeYAxisView = {
yaw: 0, yaw: 0,
pitch: 0, pitch: 0,
centerPixelX: 200, centerPixelX: 200,
@@ -151,18 +166,126 @@ test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다
centerVertical: 0, centerVertical: 0,
scale: 100, scale: 100,
}; };
const positiveX = projectArrayPosition3d([1, 0, 0], frontView); const positiveX = projectArrayPosition3d([1, 0, 0], negativeYAxisView);
const positiveZ = projectArrayPosition3d([0, 0, 1], frontView); const positiveZ = projectArrayPosition3d([0, 0, 1], negativeYAxisView);
const positiveY = projectArrayPosition3d([0, 1, 0], frontView); 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.x, 300, 1e-12, "+X projects right");
assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height"); assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally"); assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
assertNear(positiveZ.y, 20, 1e-12, "+Z FRONT projects upward"); assertNear(positiveZ.y, 20, 1e-12, "+Z projects upward");
assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth"); assertNear(negativeY.depth, 1, 1e-12, "-Y is on the viewer side");
assertNear(positiveY.depth, -1, 1e-12, "+Y is behind the origin");
const edgeView = { ...frontView, pitch: Math.PI / 2 }; });
const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView); test("Array canvas의 여섯 axis preset은 해당 축 위치에서 원점을 본다", () => {
assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically"); assertEqual(
assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis"); 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");
}); });