From 2a2e19219648ae67fe5dd1a5802721913dba4219 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Sun, 30 Aug 2026 11:40:21 +0900 Subject: [PATCH] feat: add axis-aligned 3d camera views --- README.md | 5 +- apps/array-builder/README.md | 12 +- .../beam-pattern-surface-view.js | 83 +++++++--- apps/array-builder/canvas-view.js | 92 +++++++++-- apps/array-builder/index.html | 29 +++- apps/array-builder/main.js | 48 +++++- apps/array-builder/orbit-view.js | 36 ++++ apps/array-builder/styles.css | 64 ++++++++ docs/beam-pattern.md | 5 + docs/index.html | 3 +- tests/integration/array-builder-ui.test.js | 154 +++++++++++++++++- tests/unit/array-builder-canvas.test.js | 149 +++++++++++++++-- 12 files changed, 619 insertions(+), 61 deletions(-) create mode 100644 apps/array-builder/orbit-view.js 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 @@ +