feat: add axis-aligned 3d camera views
This commit is contained in:
@@ -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)를 참고하세요.
|
||||
|
||||
@@ -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는 <code>10 log10(|B|²)</code>이며 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한 설계 조건을 확인할 때 사용합니다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" id="array-view-3d-button" type="button" aria-pressed="false">3D 검사</button>
|
||||
</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="실행 취소와 다시 실행">
|
||||
<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>
|
||||
@@ -190,6 +203,20 @@
|
||||
<option value="2.5">2.5° · 144×73</option>
|
||||
</select>
|
||||
</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">
|
||||
<span>표시 하한 (dB)</span>
|
||||
<select id="beam-pattern-floor">
|
||||
@@ -240,7 +267,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<figcaption id="beam-pattern-surface-caption">3D 표면을 준비하고 있습니다.</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ delay-and-sum weight입니다. 따라서 같은 0 dB 응답이 여러 방향에
|
||||
아닙니다. 3D 패턴은 같은 좌표계의 단위구면 방향 <code>u</code>들을 평가하므로 polar 세 단면에 없는
|
||||
대각 방향 응답도 드러냅니다.
|
||||
|
||||
배열 3D와 패턴 3D의 시점 선택은 “선택한 축 쪽에서 원점을 본다”는 규칙을 공유합니다. 예를 들어
|
||||
**+Z FRONT에서 보기**는 카메라가 +Z에 있고 원점을 향하며, 화면 오른쪽은 +X, 위는 +Y입니다.
|
||||
시점 선택과 자유 회전은 이미 계산된 좌표와 표면을 투영하는 카메라 동작일 뿐이므로 배열 좌표,
|
||||
조향 방향 <code>u0</code>, 주파수와 raw 응답을 바꾸거나 빔 패턴을 다시 계산하지 않습니다.
|
||||
|
||||
3D 표면은 구면 격자의 각 표시용 <code>displayDb</code> 표본을
|
||||
<code>rho=clamp((displayDb-dbFloor)/(0-dbFloor),0,1)</code> 반지름으로 놓고 인접 격자점을 면으로
|
||||
연결합니다. 이는 dB 동적 범위를 읽기 위한 표시 좌표이며 raw power를 바꾸지 않습니다. 격자 사이의
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@
|
||||
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
|
||||
<li>격자·선형·원형 링·Archimedean 나선·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</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><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
|
||||
</ol>
|
||||
|
||||
@@ -279,6 +279,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"3D beam caption discloses the display-only radial mapping",
|
||||
);
|
||||
await nextPaint(windowObject);
|
||||
const beamAxisView = documentObject.querySelector("#beam-pattern-axis-view");
|
||||
assertEqual(beamAxisView.value, "iso", "3D beam surface starts in isometric view");
|
||||
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam canvas exposes isometric provenance");
|
||||
const beamComputeBeforeAxisView =
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs;
|
||||
const beamFrequencyBeforeAxisView = beamSurface.dataset.frequencyHz;
|
||||
const surfaceBeforeAxisView = beamSurface.toDataURL();
|
||||
beamAxisView.focus();
|
||||
dispatchValue(windowObject, beamAxisView, "+z");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "+z", "beam surface aligns from +Z");
|
||||
assert(
|
||||
beamSurface.toDataURL() !== surfaceBeforeAxisView,
|
||||
"beam surface bitmap changes for the exact +Z camera",
|
||||
);
|
||||
dispatchValue(windowObject, beamAxisView, "+x");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "+x", "beam surface aligns from +X");
|
||||
assertEqual(
|
||||
documentObject.activeElement,
|
||||
beamAxisView,
|
||||
"beam axis selector keeps keyboard focus for consecutive comparisons",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs,
|
||||
beamComputeBeforeAxisView,
|
||||
"beam camera changes do not recompute the physical response",
|
||||
);
|
||||
assertEqual(
|
||||
beamSurface.dataset.frequencyHz,
|
||||
beamFrequencyBeforeAxisView,
|
||||
"beam camera changes preserve the evaluated analog frequency",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
initialSaveState,
|
||||
"beam camera changes stay outside Array dirty state",
|
||||
);
|
||||
const surfaceBeforeOrbit = beamSurface.toDataURL();
|
||||
const beamSurfaceRect = beamSurface.getBoundingClientRect();
|
||||
const beamOrbitPointerId = 71;
|
||||
@@ -317,6 +355,21 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
beamSurface.toDataURL() !== surfaceBeforeOrbit,
|
||||
"3D beam surface bitmap changes after orbit rotation",
|
||||
);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "custom", "beam drag enters free-orbit mode");
|
||||
assertEqual(beamAxisView.value, "custom", "beam view selector reflects free orbit");
|
||||
beamSurface.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "r",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam R shortcut restores isometric view");
|
||||
assertEqual(beamAxisView.value, "iso", "beam selector follows the R shortcut");
|
||||
assertEqual(
|
||||
documentObject.documentElement.dataset.beamPatternComputeMs,
|
||||
beamComputeBeforeAxisView,
|
||||
"beam camera reset also avoids a physical-response recompute",
|
||||
);
|
||||
documentObject.querySelector("#beam-pattern-view-cuts-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
|
||||
@@ -364,9 +417,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-front-badge").textContent.includes(
|
||||
"+Z FRONT · 초록 축",
|
||||
"시점 등각",
|
||||
),
|
||||
"3D canvas identifies the +Z front axis",
|
||||
"3D canvas identifies its isometric camera",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
|
||||
@@ -389,6 +442,41 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
coordinatesBeforeArray3d.join(","),
|
||||
"3D inspection leaves microphone coordinates unchanged",
|
||||
);
|
||||
const arrayAxisControl = documentObject.querySelector("#array-axis-view-control");
|
||||
const arrayAxisView = documentObject.querySelector("#array-axis-view");
|
||||
assert(!arrayAxisControl.hidden, "array axis selector appears only for 3D inspection");
|
||||
assertEqual(arrayAxisView.value, "iso", "array 3D starts in isometric view");
|
||||
const arraySaveStateBeforeAxisView =
|
||||
documentObject.querySelector("#save-state").textContent;
|
||||
const array3dBeforeAxisView = canvas.toDataURL();
|
||||
arrayAxisView.focus();
|
||||
dispatchValue(windowObject, arrayAxisView, "+z");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "+z", "array canvas aligns from +Z FRONT");
|
||||
dispatchValue(windowObject, arrayAxisView, "+x");
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "+x", "array canvas aligns from +X");
|
||||
assertEqual(
|
||||
documentObject.activeElement,
|
||||
arrayAxisView,
|
||||
"array axis selector keeps keyboard focus for consecutive comparisons",
|
||||
);
|
||||
assert(
|
||||
canvas.toDataURL() !== array3dBeforeAxisView,
|
||||
"array 3D bitmap changes after axis alignment",
|
||||
);
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
coordinatesBeforeArray3d.join(","),
|
||||
"axis camera changes leave microphone coordinates unchanged",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
arraySaveStateBeforeAxisView,
|
||||
"axis camera changes do not dirty the Array document",
|
||||
);
|
||||
const selectionBeforeArrayOrbit = documentObject
|
||||
.querySelector('#microphone-list [aria-selected="true"]')?.dataset.microphoneId;
|
||||
const array3dBeforeOrbit = canvas.toDataURL();
|
||||
const array3dRect = canvas.getBoundingClientRect();
|
||||
const arrayOrbitPointerId = 72;
|
||||
@@ -427,12 +515,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
canvas.toDataURL() !== array3dBeforeOrbit,
|
||||
"array 3D canvas bitmap changes after orbit rotation",
|
||||
);
|
||||
assertEqual(canvas.dataset.viewAxis, "custom", "array drag enters free-orbit mode");
|
||||
assertEqual(arrayAxisView.value, "custom", "array view selector reflects free orbit");
|
||||
assertEqual(
|
||||
documentObject.querySelector('#microphone-list [aria-selected="true"]')
|
||||
?.dataset.microphoneId,
|
||||
selectionBeforeArrayOrbit,
|
||||
"array orbit drag preserves the current channel selection",
|
||||
);
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "r",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(canvas.dataset.viewAxis, "iso", "array R shortcut restores isometric view");
|
||||
assertEqual(arrayAxisView.value, "iso", "array selector follows the R shortcut");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#save-state").textContent,
|
||||
arraySaveStateBeforeAxisView,
|
||||
"array camera reset stays outside Array dirty state",
|
||||
);
|
||||
documentObject.querySelector("#array-view-2d-button").click();
|
||||
await waitFor(
|
||||
() => canvas.dataset.viewMode === "2d" &&
|
||||
canvas.getAttribute("aria-label").includes("평면 편집"),
|
||||
"return to array 2D editing",
|
||||
);
|
||||
assert(arrayAxisControl.hidden, "array axis selector hides in 2D editing");
|
||||
assert(
|
||||
!documentObject.querySelector("#add-tool-button").disabled &&
|
||||
!documentObject.querySelector("#select-tool-button").disabled,
|
||||
@@ -1138,6 +1248,18 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
0.03,
|
||||
"compact 3D beam surface preserves its aspect ratio",
|
||||
);
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(beamSurface).touchAction,
|
||||
"pan-y",
|
||||
"compact beam 3D view preserves vertical page scrolling",
|
||||
);
|
||||
const compactBeamAxisRect = documentObject.querySelector("#beam-pattern-axis-view")
|
||||
.getBoundingClientRect();
|
||||
assert(
|
||||
compactBeamAxisRect.left >= compactBeamSurfaceCardRect.left - 1 &&
|
||||
compactBeamAxisRect.right <= compactBeamSurfaceCardRect.right + 1,
|
||||
"compact beam axis selector stays inside its analysis card",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
@@ -1147,6 +1269,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||
"Array canvas remains usable at 390px",
|
||||
);
|
||||
documentObject.querySelector("#array-view-3d-button").click();
|
||||
await waitFor(() => canvas.dataset.viewMode === "3d", "compact array 3D mode");
|
||||
const compactAxisControlRect = arrayAxisControl.getBoundingClientRect();
|
||||
const compactToolbarRect = documentObject.querySelector(".workspace-toolbar")
|
||||
.getBoundingClientRect();
|
||||
assert(
|
||||
compactAxisControlRect.left >= compactToolbarRect.left - 1 &&
|
||||
compactAxisControlRect.right <= compactToolbarRect.right + 1,
|
||||
"compact array axis selector stays inside its toolbar",
|
||||
);
|
||||
const compactFrontBadge = documentObject.querySelector("#canvas-front-badge");
|
||||
assert(
|
||||
compactFrontBadge.scrollWidth <= compactFrontBadge.clientWidth + 1 &&
|
||||
windowObject.getComputedStyle(compactFrontBadge).whiteSpace !== "nowrap",
|
||||
"compact 3D direction badge wraps without clipping axis and FRONT cues",
|
||||
);
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(canvas).touchAction,
|
||||
"pan-y",
|
||||
"compact array 3D view preserves vertical page scrolling",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
"array axis selector creates no horizontal overflow at 390px",
|
||||
);
|
||||
documentObject.querySelector("#array-view-2d-button").click();
|
||||
await waitFor(() => canvas.dataset.viewMode === "2d", "return from compact array 3D");
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
|
||||
"pan-y",
|
||||
|
||||
@@ -9,11 +9,26 @@ import {
|
||||
niceGridStep,
|
||||
projectArrayPosition3d,
|
||||
} from "/apps/array-builder/canvas-view.js";
|
||||
import {
|
||||
ORBIT_VIEW_DIRECTIONS,
|
||||
orbitForViewDirection,
|
||||
} from "/apps/array-builder/orbit-view.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
|
||||
}
|
||||
|
||||
function projectionViewport(orbit) {
|
||||
return {
|
||||
...orbit,
|
||||
centerPixelX: 200,
|
||||
centerPixelY: 120,
|
||||
centerHorizontal: 0,
|
||||
centerVertical: 0,
|
||||
scale: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
aperture: { width: 0.4, height: 0.2 },
|
||||
@@ -141,8 +156,8 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
|
||||
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
||||
});
|
||||
|
||||
test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
|
||||
const frontView = {
|
||||
test("Array canvas yaw=0, pitch=0 projection은 -Y축 위치에서 원점을 본다", () => {
|
||||
const negativeYAxisView = {
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
centerPixelX: 200,
|
||||
@@ -151,18 +166,126 @@ test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다
|
||||
centerVertical: 0,
|
||||
scale: 100,
|
||||
};
|
||||
const positiveX = projectArrayPosition3d([1, 0, 0], frontView);
|
||||
const positiveZ = projectArrayPosition3d([0, 0, 1], frontView);
|
||||
const positiveY = projectArrayPosition3d([0, 1, 0], frontView);
|
||||
const positiveX = projectArrayPosition3d([1, 0, 0], negativeYAxisView);
|
||||
const positiveZ = projectArrayPosition3d([0, 0, 1], negativeYAxisView);
|
||||
const positiveY = projectArrayPosition3d([0, 1, 0], negativeYAxisView);
|
||||
const negativeY = projectArrayPosition3d([0, -1, 0], negativeYAxisView);
|
||||
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
|
||||
assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
|
||||
assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
|
||||
assertNear(positiveZ.y, 20, 1e-12, "+Z FRONT projects upward");
|
||||
assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth");
|
||||
|
||||
const edgeView = { ...frontView, pitch: Math.PI / 2 };
|
||||
const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
|
||||
const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView);
|
||||
assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically");
|
||||
assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis");
|
||||
assertNear(positiveZ.y, 20, 1e-12, "+Z projects upward");
|
||||
assertNear(negativeY.depth, 1, 1e-12, "-Y is on the viewer side");
|
||||
assertNear(positiveY.depth, -1, 1e-12, "+Y is behind the origin");
|
||||
});
|
||||
|
||||
test("Array canvas의 여섯 axis preset은 해당 축 위치에서 원점을 본다", () => {
|
||||
assertEqual(
|
||||
ORBIT_VIEW_DIRECTIONS.join(","),
|
||||
"iso,+x,-x,+y,-y,+z,-z",
|
||||
"public preset order",
|
||||
);
|
||||
const axisVectors = {
|
||||
"+x": [1, 0, 0],
|
||||
"-x": [-1, 0, 0],
|
||||
"+y": [0, 1, 0],
|
||||
"-y": [0, -1, 0],
|
||||
"+z": [0, 0, 1],
|
||||
"-z": [0, 0, -1],
|
||||
};
|
||||
|
||||
for (const direction of ORBIT_VIEW_DIRECTIONS.slice(1)) {
|
||||
const viewport = projectionViewport(orbitForViewDirection(direction));
|
||||
const cameraSide = projectArrayPosition3d(axisVectors[direction], viewport);
|
||||
const farSide = projectArrayPosition3d(
|
||||
axisVectors[direction].map((coordinate) => -coordinate),
|
||||
viewport,
|
||||
);
|
||||
assertNear(cameraSide.x, 200, 1e-12, direction + " camera-side x");
|
||||
assertNear(cameraSide.y, 120, 1e-12, direction + " camera-side y");
|
||||
assertNear(cameraSide.depth, 1, 1e-12, direction + " camera-side depth");
|
||||
assertNear(farSide.x, 200, 1e-12, direction + " far-side x");
|
||||
assertNear(farSide.y, 120, 1e-12, direction + " far-side y");
|
||||
assertNear(farSide.depth, -1, 1e-12, direction + " far-side depth");
|
||||
}
|
||||
});
|
||||
|
||||
test("Array canvas +Z preset은 +X를 오른쪽, +Y를 위쪽으로 투영한다", () => {
|
||||
const positiveZView = projectionViewport(orbitForViewDirection("+z"));
|
||||
const positiveX = projectArrayPosition3d([1, 0, 0], positiveZView);
|
||||
const positiveY = projectArrayPosition3d([0, 1, 0], positiveZView);
|
||||
const positiveZ = projectArrayPosition3d([0, 0, 1], positiveZView);
|
||||
|
||||
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
|
||||
assertNear(positiveX.y, 120, 1e-12, "+X stays vertically centered");
|
||||
assertNear(positiveY.x, 200, 1e-12, "+Y stays horizontally centered");
|
||||
assertNear(positiveY.y, 20, 1e-12, "+Y projects up");
|
||||
assertNear(positiveZ.x, 200, 1e-12, "+Z stays horizontally centered");
|
||||
assertNear(positiveZ.y, 120, 1e-12, "+Z stays vertically centered");
|
||||
assertNear(positiveZ.depth, 1, 1e-12, "+Z points toward the viewer");
|
||||
});
|
||||
|
||||
test("Array canvas iso preset은 유한하고 비퇴화된 orthographic view다", () => {
|
||||
const implicitIso = orbitForViewDirection();
|
||||
const explicitIso = orbitForViewDirection("iso");
|
||||
assert(Number.isFinite(explicitIso.yaw), "iso yaw is finite");
|
||||
assert(Number.isFinite(explicitIso.pitch), "iso pitch is finite");
|
||||
assert(Math.abs(explicitIso.pitch) < Math.PI / 2, "iso pitch avoids a pole");
|
||||
assertNear(explicitIso.yaw, -Math.PI / 4, 1e-12, "iso yaw is canonical");
|
||||
assertNear(
|
||||
explicitIso.pitch,
|
||||
Math.asin(1 / Math.sqrt(3)),
|
||||
1e-12,
|
||||
"iso pitch is equally inclined to XYZ",
|
||||
);
|
||||
assertNear(implicitIso.yaw, explicitIso.yaw, 1e-12, "default yaw is iso");
|
||||
assertNear(implicitIso.pitch, explicitIso.pitch, 1e-12, "default pitch is iso");
|
||||
|
||||
const viewport = projectionViewport(explicitIso);
|
||||
const basis = [
|
||||
projectArrayPosition3d([1, 0, 0], viewport),
|
||||
projectArrayPosition3d([0, 1, 0], viewport),
|
||||
projectArrayPosition3d([0, 0, 1], viewport),
|
||||
];
|
||||
for (const [index, point] of basis.entries()) {
|
||||
assert(
|
||||
[point.x, point.y, point.depth].every(Number.isFinite),
|
||||
"iso basis projection " + index + " is finite",
|
||||
);
|
||||
assert(
|
||||
Math.hypot(point.x - 200, point.y - 120) > 1,
|
||||
"iso basis projection " + index + " does not collapse to the origin",
|
||||
);
|
||||
}
|
||||
const xVector = [basis[0].x - 200, basis[0].y - 120];
|
||||
const yVector = [basis[1].x - 200, basis[1].y - 120];
|
||||
const zVector = [basis[2].x - 200, basis[2].y - 120];
|
||||
assertNear(
|
||||
Math.hypot(...xVector),
|
||||
Math.hypot(...yVector),
|
||||
1e-10,
|
||||
"iso X/Y projected unit lengths match",
|
||||
);
|
||||
assertNear(
|
||||
Math.hypot(...xVector),
|
||||
Math.hypot(...zVector),
|
||||
1e-10,
|
||||
"iso X/Z projected unit lengths match",
|
||||
);
|
||||
assert(
|
||||
Math.abs(xVector[0] * yVector[1] - xVector[1] * yVector[0]) > 1,
|
||||
"iso projected X/Y basis spans a plane",
|
||||
);
|
||||
});
|
||||
|
||||
test("Array canvas orbit preset은 알 수 없는 direction을 거부한다", () => {
|
||||
let error = null;
|
||||
try {
|
||||
orbitForViewDirection("front");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
assert(error instanceof RangeError, "invalid direction throws RangeError");
|
||||
assert(error.message.includes("iso"), "error lists the supported directions");
|
||||
assert(error.message.includes("+z"), "error includes +Z");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user