diff --git a/apps/array-builder/README.md b/apps/array-builder/README.md index 371ebfd..4b76eaf 100644 --- a/apps/array-builder/README.md +++ b/apps/array-builder/README.md @@ -59,6 +59,11 @@ Array JSON을 저장하세요. - **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다. 확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다. +터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로 +스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로 +스크롤하려는 동작으로 채널이 생기지 않습니다. +`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수 +있습니다. 다시 실행하면 불러온 배열로 돌아갑니다. ## 보정값 보존 diff --git a/apps/array-builder/canvas-view.js b/apps/array-builder/canvas-view.js index b72d361..870a4d3 100644 --- a/apps/array-builder/canvas-view.js +++ b/apps/array-builder/canvas-view.js @@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714"; const MAX_PIXEL_RATIO = 3; const MIN_ZOOM = 0.25; const MAX_ZOOM = 16; +const TOUCH_GESTURE_THRESHOLD_PX = 6; const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 }); export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]); @@ -97,6 +98,26 @@ export function fitArrayViewport(width, height, arrayDefinition, options = {}) { const centerY = (contentYMin + contentYMax) / 2; const visibleWidth = plot.width / scale; const visibleHeight = plot.height / scale; + const fittedValues = [ + scale, + 1 / scale, + centerX, + centerY, + visibleWidth, + visibleHeight, + centerX - visibleWidth / 2, + centerX + visibleWidth / 2, + centerY - visibleHeight / 2, + centerY + visibleHeight / 2, + ]; + if (fittedValues.some((value) => !Number.isFinite(value))) { + return fitArrayViewport( + canvasWidth, + canvasHeight, + { aperture, microphones: [] }, + options, + ); + } return { width: canvasWidth, @@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) { const microphone = microphones[index]; if (!validPosition(microphone?.position)) continue; const pixel = arrayPositionToCanvas(microphone.position, viewport); + if (!Number.isFinite(pixel.x) || !Number.isFinite(pixel.y)) continue; const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y); if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) { best = { id: microphone.id, index, microphone, point: pixel, distancePx }; @@ -359,13 +381,14 @@ function drawMicrophones(context, model, viewport, drag) { const position = drag?.id === microphone.id ? drag.last : microphone.position; const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position }; const point = arrayPositionToCanvas(position, viewport); + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return; const isSelected = microphone.id === selectedId; const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape); const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius; context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)"; context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)"; - context.shadowBlur = isSelected ? 16 : 6; + context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6; context.beginPath(); context.arc(point.x, point.y, radius, 0, Math.PI * 2); context.fill(); @@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) { let zoom = 1; let tool = "select"; let drag = null; + let pendingAdd = null; if (canvas.tabIndex < 0) canvas.tabIndex = 0; - if (canvas.style) canvas.style.touchAction = "none"; - function updateCanvasAffordance() { if (canvas.dataset) { canvas.dataset.tool = tool; @@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) { function setTool(nextTool) { const resolved = normalizedTool(nextTool); if (resolved === tool) return tool; + if (resolved !== "add") pendingAdd = null; tool = resolved; updateCanvasAffordance(); callbacks.onToolChange?.(tool); @@ -550,14 +573,48 @@ export function createArrayCanvas(canvas, callbacks = {}) { return eventToCanvasPoint(event, canvas.getBoundingClientRect()); } + function clientPoint(event) { + return { + x: finiteNumber(event?.clientX, 0), + y: finiteNumber(event?.clientY, 0), + }; + } + + function exceedsTouchThreshold(start, event) { + const current = clientPoint(event); + return Math.hypot(current.x - start.x, current.y - start.y) > + TOUCH_GESTURE_THRESHOLD_PX; + } + + function onTouchStart(event) { + if (destroyed || tool !== "select" || !model?.arrayDefinition) return; + const touch = event.changedTouches?.[0] ?? event.touches?.[0]; + const currentViewport = ensureViewport(); + if (!touch || !currentViewport) return; + const hit = hitTestMicrophone( + pointerPoint(touch), + model.arrayDefinition.microphones, + currentViewport, + ); + if (hit) event.preventDefault(); + } + function onPointerDown(event) { if (destroyed || event.button !== 0 || !model?.arrayDefinition) return; + if (drag || pendingAdd) return; focus(); if (tool === "add") { const position = constrainedEventPosition(event, 0); if (position) { - event.preventDefault(); - callbacks.onAdd?.(position); + if (event.pointerType === "touch") { + pendingAdd = { + pointerId: event.pointerId, + startClient: clientPoint(event), + }; + } else { + event.preventDefault(); + callbacks.onAdd?.(position); + } } return; } @@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) { id: hit.id, start: [...hit.microphone.position], last: [...hit.microphone.position], + startClient: clientPoint(event), + pointerType: event.pointerType, + pending: event.pointerType === "touch", }; - canvas.setPointerCapture?.(event.pointerId); - callbacks.onDragStart?.(hit.id); + try { + canvas.setPointerCapture?.(event.pointerId); + } catch { + // Synthetic pointers and interrupted input do not always own capture. + } + if (!drag.pending) callbacks.onDragStart?.(hit.id); updateCanvasAffordance(); schedule(); } function onPointerMove(event) { + if (pendingAdd?.pointerId === event.pointerId) { + if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null; + return; + } if (!drag || event.pointerId !== drag.pointerId) return; + if (drag.pending) { + if (!exceedsTouchThreshold(drag.startClient, event)) return; + drag.pending = false; + callbacks.onDragStart?.(drag.id); + } const microphone = microphoneById(drag.id); const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]); if (!position) return; @@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) { if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return; const completed = drag; drag = null; - const position = cancel ? completed.start : completed.last; - if (cancel) callbacks.onDrag?.(completed.id, [...position]); - callbacks.onDragEnd?.(completed.id, [...position]); + if (!completed.pending) { + const position = cancel ? completed.start : completed.last; + if (cancel) callbacks.onDrag?.(completed.id, [...position]); + callbacks.onDragEnd?.(completed.id, [...position]); + } const pointerId = event?.pointerId ?? completed.pointerId; - if (canvas.hasPointerCapture?.(pointerId)) { - canvas.releasePointerCapture?.(pointerId); + try { + if (canvas.hasPointerCapture?.(pointerId)) { + canvas.releasePointerCapture?.(pointerId); + } + } catch { + // The browser may have released capture before pointercancel/lostcapture. } updateCanvasAffordance(); schedule(); } + function finishPendingAdd(event, cancel = false) { + if (!pendingAdd || event?.pointerId !== pendingAdd.pointerId) return false; + const completed = pendingAdd; + pendingAdd = null; + if (!cancel && tool === "add" && !exceedsTouchThreshold(completed.startClient, event)) { + const position = constrainedEventPosition(event, 0); + if (position) { + event.preventDefault(); + callbacks.onAdd?.(position); + } + } + return true; + } + function onKeyDown(event) { if (event.altKey || event.ctrlKey || event.metaKey) return; const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key; @@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) { } function onPointerUp(event) { + if (finishPendingAdd(event, false)) return; finishDrag(event, false); } function onPointerCancel(event) { + if (finishPendingAdd(event, true)) return; finishDrag(event, true); } @@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) { canvas.addEventListener("pointerup", onPointerUp); canvas.addEventListener("pointercancel", onPointerCancel); canvas.addEventListener("lostpointercapture", onLostPointerCapture); + canvas.addEventListener("touchstart", onTouchStart, { passive: false }); canvas.addEventListener("keydown", onKeyDown); let resizeObserver = null; @@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) { canvas.removeEventListener("pointerup", onPointerUp); canvas.removeEventListener("pointercancel", onPointerCancel); canvas.removeEventListener("lostpointercapture", onLostPointerCapture); + canvas.removeEventListener("touchstart", onTouchStart); canvas.removeEventListener("keydown", onKeyDown); model = null; viewport = null; drag = null; + pendingAdd = null; }, }; } diff --git a/apps/array-builder/index.html b/apps/array-builder/index.html index 6edd8f5..ea2ebee 100644 --- a/apps/array-builder/index.html +++ b/apps/array-builder/index.html @@ -12,6 +12,7 @@ +
@@ -39,6 +40,65 @@
+
+
+
+

마이크 배치

+

Array 평면 편집

+
+

0.45 × 0.45 m · 12 channels

+
+ + + +
+ +
+ 마이크를 클릭하거나 드래그해 위치를 편집하세요. + 추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다. +
+
선택된 마이크 없음
+
+ +
+
채널12microphones
+
최소 간격—nearest pair
+
무게중심—x, y, z
+
보정 채널0sensitivity / phase / response
+
+ +
+ 빠른 편집 키 +
+

V 선택 · A 추가 · Delete 선택 삭제

+

← ↑ ↓ → 위치 미세 조정 · Shift를 함께 누르면 10배 이동

+
+
+
+
+ +
+ +
-
-
-
-

마이크 배치

-

Array 평면 편집

-
-

0.45 × 0.45 m · 12 channels

-
- - - -
- -
- 마이크를 클릭하거나 드래그해 위치를 편집하세요. - 추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다. -
-
선택된 마이크 없음
-
- -
-
채널12microphones
-
최소 간격—nearest pair
-
무게중심—x, y, z
-
보정 채널0sensitivity / phase / response
-
- -
- 빠른 편집 키 -
-

V 선택 · A 추가 · Delete 선택 삭제

-

← ↑ ↓ → 위치 미세 조정 · Shift를 함께 누르면 10배 이동

-
-
-
diff --git a/apps/array-builder/main.js b/apps/array-builder/main.js index b6e221f..9b6e76e 100644 --- a/apps/array-builder/main.js +++ b/apps/array-builder/main.js @@ -49,7 +49,7 @@ const optionalIds = [ "move-microphone-down-button", "microphone-sensitivity", "microphone-phase", "frequency-response-summary", "preset-axis", "preset-edge-inset", "microphone-search", "microphone-selection-empty", "microphone-fields", - "workspace-summary", + "workspace-summary", "canvas-instructions", "mobile-export-button", ]; for (const id of optionalIds) elements[id] = maybeById(id); elements.validationCard = elements["validation-title"].closest(".validation-card"); @@ -80,14 +80,20 @@ const state = { history: [], future: [], dragSnapshot: null, + importRevision: 0, + baselineSignature: "", }; const MAX_HISTORY = 50; +const MAX_HISTORY_BYTES = 24 * 1024 * 1024; +const frequencyResponseFingerprintCache = new WeakMap(); const BUILTIN_ARRAY_IDS = new Set([ - "rectangular-2x2", "rectangular-4x3", "rectangular-8x6", - "rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact", + "rectangular-2x2-450mm", "rectangular-4x3-450mm", + "rectangular-8x6-450mm", "rectangular-16x12", + "rectangular-24x16-450mm", "rectangular-16x12-compact", "rectangular-16x12-large", "fermat-spiral-192", ]); +state.baselineSignature = editableDocumentSignature(state.arrayDefinition); function finiteInput(element, label, options = {}) { const value = element.valueAsNumber; @@ -121,35 +127,152 @@ function selectedMicrophone() { ) ?? null; } +function cloneArrayForHistory(arrayDefinition) { + return { + ...arrayDefinition, + coordinateSystem: { ...arrayDefinition.coordinateSystem }, + aperture: { ...arrayDefinition.aperture }, + microphones: arrayDefinition.microphones.map((microphone) => ({ + ...microphone, + position: [...microphone.position], + ...(microphone.frequencyResponse === undefined + ? {} + : { frequencyResponse: microphone.frequencyResponse }), + })), + }; +} + function captureSnapshot() { return { - arrayDefinition: structuredClone(state.arrayDefinition), + arrayDefinition: cloneArrayForHistory(state.arrayDefinition), apertureShape: state.apertureShape, selectedMicrophoneId: state.selectedMicrophoneId, }; } -function snapshotSignature(snapshot) { - return JSON.stringify(snapshot); +function editableSnapshotSignature(snapshot) { + const arrayDefinition = snapshot.arrayDefinition; + return JSON.stringify({ + document: editableDocumentView(arrayDefinition), + apertureShape: snapshot.apertureShape, + selectedMicrophoneId: snapshot.selectedMicrophoneId, + }); +} + +function fingerprintFrequencyResponse(response) { + if (!Array.isArray(response)) return null; + const cached = frequencyResponseFingerprintCache.get(response); + if (cached) return cached; + const canonical = JSON.stringify(response.map((point) => [ + point.frequencyHz, + point.gainDb, + point.phaseDeg ?? null, + ])); + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let index = 0; index < canonical.length; index += 1) { + const code = canonical.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193) >>> 0; + second = Math.imul(second ^ code, 0x85ebca6b) >>> 0; + } + const fingerprint = + String(canonical.length) + ":" + + first.toString(16).padStart(8, "0") + ":" + + second.toString(16).padStart(8, "0"); + frequencyResponseFingerprintCache.set(response, fingerprint); + return fingerprint; +} + +function editableDocumentView(arrayDefinition) { + return { + schemaVersion: arrayDefinition.schemaVersion, + kind: arrayDefinition.kind, + id: arrayDefinition.id, + name: arrayDefinition.name, + description: arrayDefinition.description, + units: arrayDefinition.units, + coordinateSystem: arrayDefinition.coordinateSystem, + aperture: arrayDefinition.aperture, + microphones: arrayDefinition.microphones.map((microphone) => [ + microphone.id, + ...microphone.position, + microphone.sensitivityDb ?? null, + microphone.phaseOffsetDeg ?? null, + fingerprintFrequencyResponse(microphone.frequencyResponse), + ]), + }; +} + +function editableDocumentSignature(arrayDefinition) { + return JSON.stringify(editableDocumentView(arrayDefinition)); +} + +function currentSnapshotView() { + return { + arrayDefinition: state.arrayDefinition, + apertureShape: state.apertureShape, + selectedMicrophoneId: state.selectedMicrophoneId, + }; +} + +function estimatedSnapshotBytes(snapshot) { + const responsePointCount = snapshot.arrayDefinition.microphones.reduce( + (count, microphone) => count + (microphone.frequencyResponse?.length ?? 0), + 0, + ); + return editableSnapshotSignature(snapshot).length * 4 + responsePointCount * 160; +} + +function historyEntry(snapshot, label) { + return { snapshot, label, bytes: estimatedSnapshotBytes(snapshot) }; +} + +function trimHistoryMemory() { + while (state.history.length > MAX_HISTORY) state.history.shift(); + let totalBytes = [...state.history, ...state.future].reduce( + (sum, entry) => sum + (entry.bytes ?? estimatedSnapshotBytes(entry.snapshot)), + 0, + ); + while (totalBytes > MAX_HISTORY_BYTES && state.history.length > 1) { + totalBytes -= state.history.shift().bytes; + } + while (totalBytes > MAX_HISTORY_BYTES && state.future.length > 1) { + totalBytes -= state.future.shift().bytes; + } + while ( + totalBytes > MAX_HISTORY_BYTES && + state.history.length + state.future.length > 1 + ) { + const removed = state.history.length + ? state.history.shift() + : state.future.shift(); + totalBytes -= removed.bytes; + } +} + +function refreshDirtyState() { + state.dirty = editableDocumentSignature(state.arrayDefinition) !== state.baselineSignature; } function restoreSnapshot(snapshot, message) { - state.arrayDefinition = structuredClone(snapshot.arrayDefinition); + state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition); state.apertureShape = snapshot.apertureShape; state.selectedMicrophoneId = snapshot.selectedMicrophoneId; if (!selectedMicrophone()) { state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; } - state.dirty = true; + refreshDirtyState(); renderAll(message); } function recordSnapshot(before, label) { - if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false; - state.history.push({ snapshot: before, label }); - if (state.history.length > MAX_HISTORY) state.history.shift(); + if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) { + return false; + } + state.history.push(historyEntry(before, label)); state.future.length = 0; - state.dirty = true; + trimHistoryMemory(); + refreshDirtyState(); return true; } @@ -165,7 +288,11 @@ function showActionError(error) { if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함"; elements["validation-title"].textContent = "작업을 적용하지 못했습니다"; elements["validation-message"].textContent = message; - if (elements.validationCard) elements.validationCard.dataset.severity = "error"; + if (elements.validationCard) { + elements.validationCard.dataset.severity = "error"; + elements.validationCard.classList.add("is-invalid"); + elements.validationCard.classList.remove("is-valid", "is-warning"); + } elements["array-builder-workspace"].dataset.actionError = message; } @@ -174,6 +301,9 @@ function safely(callback) { delete elements["array-builder-workspace"].dataset.actionError; callback(); } catch (error) { + renderDocumentEditor(); + syncSelectedEditor(); + setNumber(elements["snap-step"], state.snapStepM * 1000); showActionError(error); } } @@ -183,9 +313,14 @@ function announce(message) { elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨"; elements["save-state"].classList.toggle("is-dirty", state.dirty); } - if (elements["selection-status"] && message) { - elements["selection-status"].textContent = message; - } +} + +function announceSelection(message) { + if (elements["selection-status"]) elements["selection-status"].textContent = message; +} + +function dismissCanvasInstructions() { + elements["canvas-instructions"]?.classList.add("is-hidden"); } function setTool(tool, options = {}) { @@ -196,6 +331,10 @@ function setTool(tool, options = {}) { elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add")); elements["array-canvas"].dataset.tool = state.tool; canvasView.render(canvasState()); + if (options.userInitiated) { + dismissCanvasInstructions(); + announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구"); + } if (options.focus) canvasView.focus(); } @@ -258,15 +397,29 @@ function selectMicrophone(id, options = {}) { renderMicrophoneList(); syncSelectedEditor(); canvasView.render(canvasState()); - if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택"); + const selectedButton = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")] + .find((button) => button.dataset.microphoneId === state.selectedMicrophoneId); + if (options.focusList) selectedButton?.focus(); + if (options.scrollList !== false) { + selectedButton?.scrollIntoView?.({ block: "nearest" }); + } + announceSelection( + state.selectedMicrophoneId + ? state.selectedMicrophoneId + " 선택" + : "선택된 마이크 없음", + ); if (options.focusCanvas) canvasView.focus(); } const canvasView = createArrayCanvas(elements["array-canvas"], { onSelect(id) { - selectMicrophone(id); + dismissCanvasInstructions(); + selectMicrophone(id, { + scrollList: window.matchMedia("(min-width: 761px)").matches, + }); }, onAdd(position) { + dismissCanvasInstructions(); safely(() => mutate("마이크 추가됨", () => { const microphone = addMicrophone( state.arrayDefinition, @@ -276,6 +429,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], { })); }, onDragStart(id) { + dismissCanvasInstructions(); if (state.dragSnapshot) return; state.selectedMicrophoneId = id; state.dragSnapshot = captureSnapshot(); @@ -320,7 +474,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], { })); }, onToolChange(tool) { - setTool(tool); + setTool(tool, { userInitiated: true }); }, onFit() { canvasView.fit(); @@ -372,11 +526,14 @@ function renderMicrophoneList() { const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? ""; list.replaceChildren(); state.arrayDefinition.microphones.forEach((microphone, index) => { - if (query && !microphone.id.toLowerCase().includes(query)) return; + const matchesQuery = !query || microphone.id.toLowerCase().includes(query); + if (!matchesQuery && microphone.id !== state.selectedMicrophoneId) return; const button = document.createElement("button"); button.type = "button"; button.className = "microphone-row"; button.dataset.microphoneId = microphone.id; + button.dataset.searchPinned = String(!matchesQuery); + button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1; button.setAttribute("role", "option"); button.setAttribute( "aria-selected", @@ -412,9 +569,16 @@ function renderMicrophoneList() { identity.append(calibration); } button.append(channel, identity); - button.addEventListener("click", () => selectMicrophone(microphone.id)); + button.addEventListener("click", () => { + dismissCanvasInstructions(); + selectMicrophone(microphone.id, { focusList: true }); + }); list.append(button); }); + if (!list.querySelector('[tabindex="0"]')) { + const firstButton = list.querySelector("[data-microphone-id]"); + if (firstButton) firstButton.tabIndex = 0; + } if (!list.childElementCount) { const empty = document.createElement("p"); empty.className = "microphone-list__empty"; @@ -521,6 +685,9 @@ async function validateCurrent() { state.valid = errors.length === 0; document.documentElement.dataset.arrayValid = String(state.valid); elements["export-button"].disabled = !state.valid; + if (elements["mobile-export-button"]) { + elements["mobile-export-button"].disabled = !state.valid; + } elements.validationCard?.setAttribute( "data-severity", errors.length ? "error" : warnings.length ? "warning" : "success", @@ -603,24 +770,31 @@ function applySelectedPosition() { } function presetOptions() { - return { - type: elements["preset-type"].value, + const type = elements["preset-type"].value; + const options = { + type, aperture: state.arrayDefinition.aperture, shape: state.apertureShape, - count: integerInput(elements["preset-count"], "마이크 수", 1, 1024), - columns: integerInput(elements["preset-columns"], "Columns", 1, 1024), - rows: integerInput(elements["preset-rows"], "Rows", 1, 1024), - seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295), - minimumSpacingM: finiteInput( - elements["preset-minimum-spacing"], - "최소 간격", - { minimum: 0 }, - ), edgeInsetM: elements["preset-edge-inset"] ? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 }) : 0, - axis: elements["preset-axis"]?.value ?? "x", }; + if (type === "grid") { + options.columns = integerInput(elements["preset-columns"], "Columns", 1, 1024); + options.rows = integerInput(elements["preset-rows"], "Rows", 1, 1024); + } else { + options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024); + } + if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x"; + if (type === "random") { + options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295); + options.minimumSpacingM = finiteInput( + elements["preset-minimum-spacing"], + "최소 간격", + { minimum: 0 }, + ); + } + return options; } elements["array-id"].addEventListener("change", () => safely(() => { @@ -680,8 +854,10 @@ elements["generate-preset-button"].addEventListener("click", () => safely(() => ); })); -elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true })); -elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true })); +elements["select-tool-button"].addEventListener("click", () => + setTool("select", { focus: true, userInitiated: true })); +elements["add-tool-button"].addEventListener("click", () => + setTool("add", { focus: true, userInitiated: true })); 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)); @@ -701,6 +877,7 @@ elements["snap-step"].addEventListener("change", () => safely(() => { })); elements["add-microphone-button"].addEventListener("click", () => safely(() => { + dismissCanvasInstructions(); mutate("마이크 추가됨", () => { const microphone = addMicrophone(state.arrayDefinition, availableManualPosition()); state.selectedMicrophoneId = microphone.id; @@ -763,6 +940,19 @@ elements["move-microphone-down-button"]?.addEventListener("click", () => safely( mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1)); })); elements["microphone-search"]?.addEventListener("input", renderMicrophoneList); +elements["microphone-list"].addEventListener("keydown", (event) => { + if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return; + const buttons = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")]; + if (!buttons.length) return; + const focusedIndex = Math.max(0, buttons.indexOf(document.activeElement)); + let targetIndex = focusedIndex; + if (event.key === "ArrowUp") targetIndex = Math.max(0, focusedIndex - 1); + else if (event.key === "ArrowDown") targetIndex = Math.min(buttons.length - 1, focusedIndex + 1); + else if (event.key === "Home") targetIndex = 0; + else targetIndex = buttons.length - 1; + event.preventDefault(); + selectMicrophone(buttons[targetIndex].dataset.microphoneId, { focusList: true }); +}); elements["center-microphones-button"]?.addEventListener("click", () => safely(() => { mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition)); @@ -789,14 +979,16 @@ elements["clamp-microphones-button"]?.addEventListener("click", () => safely(() function undo() { const entry = state.history.pop(); if (!entry) return; - state.future.push({ snapshot: captureSnapshot(), label: entry.label }); + state.future.push(historyEntry(captureSnapshot(), entry.label)); + trimHistoryMemory(); restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label); } function redo() { const entry = state.future.pop(); if (!entry) return; - state.history.push({ snapshot: captureSnapshot(), label: entry.label }); + state.history.push(historyEntry(captureSnapshot(), entry.label)); + trimHistoryMemory(); restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label); } @@ -821,31 +1013,36 @@ window.addEventListener("keydown", (event) => { elements["array-input"].addEventListener("change", async () => { const file = elements["array-input"].files?.[0]; if (!file) return; + const importRevision = ++state.importRevision; try { const value = await readJsonFile(file); const issues = await validateArrayDefinition(value); + if (importRevision !== state.importRevision) return; if (issues.length) { const details = issues.slice(0, 4).map( (issue) => issue.path + ": " + issue.message, ).join(" · "); throw new Error("Array JSON 계약을 만족하지 않습니다. " + details); } + const previousSnapshot = captureSnapshot(); state.arrayDefinition = prepareArrayForEditing(value); state.apertureShape = inferApertureShape(value); state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; - state.history.length = 0; + state.history = [historyEntry(previousSnapshot, "Array 불러오기")]; state.future.length = 0; + trimHistoryMemory(); + state.baselineSignature = editableDocumentSignature(state.arrayDefinition); state.dirty = false; canvasView.fit(); - renderAll(file.name + " 불러옴"); + renderAll(file.name + " 불러옴 · 실행 취소 가능"); } catch (error) { - showActionError(error); + if (importRevision === state.importRevision) showActionError(error); } finally { - elements["array-input"].value = ""; + if (importRevision === state.importRevision) elements["array-input"].value = ""; } }); -elements["export-button"].addEventListener("click", async () => { +async function exportArray() { await validateCurrent(); if (!state.valid) { showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요.")); @@ -855,9 +1052,13 @@ elements["export-button"].addEventListener("click", async () => { state.arrayDefinition, safeArrayFileStem(state.arrayDefinition) + ".array.json", ); - state.dirty = false; + state.baselineSignature = editableDocumentSignature(state.arrayDefinition); + refreshDirtyState(); announce("Array JSON 저장됨"); -}); +} + +elements["export-button"].addEventListener("click", exportArray); +elements["mobile-export-button"]?.addEventListener("click", exportArray); window.addEventListener("beforeunload", (event) => { if (!state.dirty) return; diff --git a/apps/array-builder/model.js b/apps/array-builder/model.js index 99aa90a..4b8858d 100644 --- a/apps/array-builder/model.js +++ b/apps/array-builder/model.js @@ -63,7 +63,10 @@ function microphoneFromPosition(position, index) { } function pointKey(position) { - return position.map((value) => rounded(value).toFixed(COORDINATE_DIGITS)).join(","); + return position.map((value) => { + const number = finiteNumber(value, "좌표"); + return Object.is(number, -0) ? "0" : String(number); + }).join(","); } function linearValue(index, count, minimum, maximum) { @@ -218,16 +221,43 @@ function randomPositions(options, bounds, shape) { const random = createSeededRandom(options.seed ?? 1); const positions = []; const keys = new Set(); - const maximumAttempts = Math.max(2000, count * 500); + const cellSize = Math.max(minimumSpacing, 10 ** -COORDINATE_DIGITS); + const spatialCells = minimumSpacingSquared > 0 ? new Map() : null; + const cellCoordinates = (position) => [ + Math.floor(position[0] / cellSize), + Math.floor(position[1] / cellSize), + ]; + const cellKey = (x, y) => String(x) + "," + String(y); + const violatesMinimumSpacing = (candidate) => { + if (!spatialCells) return false; + const [cellX, cellY] = cellCoordinates(candidate); + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + const neighbors = spatialCells.get(cellKey(cellX + offsetX, cellY + offsetY)); + if (neighbors?.some( + (position) => squaredDistance(position, candidate) < minimumSpacingSquared, + )) return true; + } + } + return false; + }; + const indexPosition = (position) => { + if (!spatialCells) return; + const [cellX, cellY] = cellCoordinates(position); + const key = cellKey(cellX, cellY); + const cell = spatialCells.get(key); + if (cell) cell.push(position); + else spatialCells.set(key, [position]); + }; + const maximumAttempts = Math.max(2000, count * 150); for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) { const candidate = normalizedPosition(randomCandidate(random, bounds, shape)); const key = pointKey(candidate); if (keys.has(key)) continue; - if (positions.some((position) => squaredDistance(position, candidate) < minimumSpacingSquared)) { - continue; - } + if (violatesMinimumSpacing(candidate)) continue; keys.add(key); positions.push(candidate); + indexPosition(candidate); } if (positions.length !== count) { throw new RangeError( @@ -302,35 +332,36 @@ export function prepareArrayForEditing(value) { } export function inferApertureShape(arrayDefinition) { - const description = String(arrayDefinition?.description ?? ""); - if (/circle|circular|ellipse|elliptic|ring|spiral/i.test(description)) return "ellipse"; - const aperture = arrayDefinition?.aperture; - const microphones = arrayDefinition?.microphones ?? []; - if (!aperture || microphones.length < 3) return "rectangle"; - let maximumNormalizedRadius = 0; - for (const microphone of microphones) { - const position = microphone?.position; - if (!Array.isArray(position)) return "rectangle"; - const radius = Math.hypot( - position[0] / (aperture.width / 2), - position[1] / (aperture.height / 2), - ); - if (radius > 1.000000001) return "rectangle"; - maximumNormalizedRadius = Math.max(maximumNormalizedRadius, radius); - } - return maximumNormalizedRadius >= 0.72 ? "ellipse" : "rectangle"; + const shapeWords = [ + arrayDefinition?.id, + arrayDefinition?.name, + arrayDefinition?.description, + ].filter(Boolean).join(" "); + return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords) + ? "ellipse" + : "rectangle"; } export function nextMicrophoneId(arrayDefinition) { const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id)); - let maximumSuffix = 0; + let maximumSuffix = 0n; for (const id of used) { const match = /^mic-(\d+)$/.exec(id); - if (match) maximumSuffix = Math.max(maximumSuffix, Number(match[1])); + if (!match) continue; + const suffix = BigInt(match[1]); + if (suffix > maximumSuffix) maximumSuffix = suffix; } - let candidate = maximumSuffix + 1; - while (used.has(microphoneId(candidate))) candidate += 1; - return microphoneId(candidate); + let candidate = maximumSuffix + 1n; + for (let attempt = 0; attempt <= used.size; attempt += 1) { + const id = "mic-" + candidate.toString().padStart(3, "0"); + if (id.length <= 64 && !used.has(id)) return id; + candidate += 1n; + } + for (let suffix = 1; suffix <= MAX_MICROPHONES + 1; suffix += 1) { + const id = microphoneId(suffix); + if (!used.has(id)) return id; + } + throw new Error("사용 가능한 마이크 ID를 찾을 수 없습니다."); } export function addMicrophone(arrayDefinition, position, properties = {}) { @@ -514,8 +545,19 @@ export function arrayStatistics(arrayDefinition) { if (microphones.length === 0) { return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 }; } - const centroid = [0, 1, 2].map((axis) => - microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length); + const centroid = [0, 1, 2].map((axis) => { + const scale = microphones.reduce( + (maximum, microphone) => Math.max(maximum, Math.abs(microphone.position[axis])), + 0, + ); + if (scale === 0) return 0; + const normalizedSum = microphones.reduce( + (sum, microphone) => sum + microphone.position[axis] / scale, + 0, + ); + const normalizedMean = Math.max(-1, Math.min(1, normalizedSum / microphones.length)); + return normalizedMean * scale; + }); let minimumSpacingSquared = Infinity; for (let left = 0; left < microphones.length; left += 1) { for (let right = left + 1; right < microphones.length; right += 1) { diff --git a/apps/array-builder/styles.css b/apps/array-builder/styles.css index d8c1152..d4e2185 100644 --- a/apps/array-builder/styles.css +++ b/apps/array-builder/styles.css @@ -3,6 +3,25 @@ overflow-x: clip; } +.skip-link { + position: fixed; + z-index: 100; + top: 8px; + left: 8px; + padding: 9px 12px; + border-radius: 9px; + color: #06130f; + background: var(--mint); + font-size: 0.72rem; + font-weight: 750; + text-decoration: none; + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + .array-builder-header { position: sticky; z-index: 30; @@ -116,6 +135,11 @@ clip-path: inset(50%); } +.file-button:focus-within { + border-color: var(--mint); + outline: 2px solid rgb(101 242 191 / 16%); +} + .array-workflow { min-width: 0; margin-left: auto; @@ -169,6 +193,8 @@ } .array-controls { + grid-column: 1; + grid-row: 1; position: sticky; top: 59px; min-width: 0; @@ -185,6 +211,8 @@ } .array-builder-layout > .split-handle { + grid-column: 2; + grid-row: 1; position: sticky; top: 59px; height: calc(100vh - 59px); @@ -577,7 +605,13 @@ details > summary::-webkit-details-marker { padding: 0 10px 10px; } +.mobile-save-actions { + display: none; +} + .array-workspace { + grid-column: 3; + grid-row: 1; min-width: 0; padding: 18px clamp(18px, 2.5vw, 38px) 38px; } @@ -857,41 +891,60 @@ body[data-array-tool="add"] #array-canvas, @media (max-width: 1100px) { .array-builder-layout { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(340px, 38%) minmax(0, 1fr); } .array-builder-layout > .split-handle { display: none; } + .array-controls { + grid-column: 1; + } + + .array-workspace { + grid-column: 2; + } + .array-controls, .array-workspace { width: 100%; min-width: 0; } - .array-controls { - position: static; - overflow: visible; - max-height: none; - border-right: 0; - border-bottom: 1px solid var(--line); - } - .microphone-list { max-height: 360px; } } @media (max-width: 760px) { + .array-builder-layout { + grid-template-columns: minmax(0, 1fr); + } + .array-builder-header { position: static; flex-wrap: wrap; gap: 10px; } + .array-workspace { + grid-column: 1; + grid-row: 1; + } + + .array-controls { + grid-column: 1; + grid-row: 2; + position: static; + overflow: visible; + max-height: none; + border-right: 0; + border-top: 1px solid var(--line); + border-bottom: 0; + } + .array-workflow { - order: 3; width: 100%; margin: 0; } @@ -935,11 +988,17 @@ body[data-array-tool="add"] #array-canvas, #array-canvas { height: 500px; + touch-action: pan-y; } .shortcut-help > div { grid-template-columns: minmax(0, 1fr); } + + .mobile-save-actions { + display: block; + padding: 2px 0 16px; + } } @media (max-width: 520px) { diff --git a/docs/data-contracts.md b/docs/data-contracts.md index b6bbc40..80d55bd 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -24,6 +24,11 @@ array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가까운 끝점을 사용한다. +Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백, +snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는 +`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는 +형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다. + Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와 raw map 계약은 다음과 같다. diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 929dd36..19693b4 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -37,13 +37,15 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안 때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를 남긴다. -두 앱의 책임은 다음과 같이 분리한다. +세 앱의 책임은 다음과 같이 분리한다. +- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random + 프리셋, 마이크 직접 편집·채널 순서·보정 보존과 표준 Array JSON 내보내기 - `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정, seed 고정, 합성 오디오와 ground truth manifest 내보내기 - `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성, DAS 실행, overlay·수치 평가·실험 결과 내보내기 -- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답, +- `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답, STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer - `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark @@ -52,7 +54,7 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안 JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는 Hz, 좌표는 우수 좌표계로 명시한다. -- `array.schema.json`: schema version, 배열 폭·높이, 192개 마이크의 id와 `[x,y,z]`, 선택적 +- `array.schema.json`: schema version, 배열 폭·높이, 1–1,024개 마이크의 id와 `[x,y,z]`, 선택적 감도·위상·주파수 응답 보정값 - `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료· gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경 @@ -145,7 +147,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로 - 좌표계·단위·오디오 normalization 결정 기록 - synthetic single-tone scene과 192채널 기준 배열 작성 -완료 조건: 예제 JSON이 schema validation을 통과하고 두 앱이 동일 manifest를 읽는다. +완료 조건: 예제 JSON이 schema validation을 통과하고 앱들이 동일 계약을 읽는다. ### 단계 1 — CPU 기준 모델 · 완료 @@ -164,6 +166,15 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로 완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다. +### 단계 2A — Array Builder · 완료 + +- aperture 크기와 사각/타원 생성 경계, 5종 결정론적 프리셋 구현 +- canvas 추가·선택·drag, 정밀 좌표·ID·채널 순서 편집과 undo/redo +- 가져온 sensitivity·phase·frequency response 보존과 표준 Array JSON 왕복 +- 좌표 중복 오류, 경계 초과 경고, clamp·중앙 정렬·aperture 맞춤 + +완료 조건: 만든 Array가 계약 검증을 통과하고 Acoustic Camera에서 그대로 불러와진다. + ### 단계 3 — WebGPU DAS · 완료 - CPU와 동일한 buffer layout 및 WGSL kernel 구현 @@ -212,7 +223,8 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정 ## 10. 구현 결과와 다음 백로그 -- [x] 설치 없는 ES module workspace와 두 앱 shell +- [x] 설치 없는 ES module workspace와 세 앱 shell +- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달 - [x] array/scene/experiment/result schema와 synthetic fixture - [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트 - [x] CPU DAS golden implementation diff --git a/tests/integration/README.md b/tests/integration/README.md index 03efcf5..4cbf133 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -22,11 +22,15 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장 - `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers - `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집, trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존 +- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo, + 중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout - `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와 DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의 Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지 - `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨 Result JSON을 저장하는 end-to-end 흐름 +- `split-layout-ui.test.js`: 세 앱 설정 패널의 drag·keyboard 폭 조절, 저장·reset과 + 좁은 화면 적층 WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로 CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은 diff --git a/tests/integration/array-builder-ui.test.js b/tests/integration/array-builder-ui.test.js index dbd546b..cb9ff0f 100644 --- a/tests/integration/array-builder-ui.test.js +++ b/tests/integration/array-builder-ui.test.js @@ -1,4 +1,9 @@ import { validateArrayDefinition } from "/packages/contracts/validation.js"; +import { + arrayPositionToCanvas, + fitArrayViewport, +} from "/apps/array-builder/canvas-view.js"; +import { createArrayDefinition } from "/apps/array-builder/model.js"; function assertNear(actual, expected, tolerance, label) { assert( @@ -103,16 +108,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500, "canvas dominates the desktop workspace", ); + assert( + !documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"), + "canvas guidance starts visible", + ); + const canvas = documentObject.querySelector("#array-canvas"); + const defaultArray = createArrayDefinition(); + const initialCanvasRect = canvas.getBoundingClientRect(); + const initialViewport = fitArrayViewport( + initialCanvasRect.width, + initialCanvasRect.height, + defaultArray, + ); + const firstMicrophonePixel = arrayPositionToCanvas( + defaultArray.microphones[0].position, + initialViewport, + ); + const initialFirstPosition = selectedCoordinates(documentObject); + const dragPointerId = 21; + const dispatchDragPointer = (type, offsetX, offsetY, buttons) => { + canvas.dispatchEvent(new windowObject.PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: dragPointerId, + pointerType: "mouse", + isPrimary: true, + button: type === "pointermove" ? -1 : 0, + buttons, + clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX, + clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY, + })); + }; + dispatchDragPointer("pointerdown", 0, 0, 1); + dispatchDragPointer("pointermove", 36, -24, 1); + dispatchDragPointer("pointerup", 36, -24, 0); + await nextPaint(windowObject); + const draggedPosition = selectedCoordinates(documentObject); + assert( + draggedPosition[0] !== initialFirstPosition[0] && + draggedPosition[1] !== initialFirstPosition[1], + "pointer drag changes both array coordinates", + ); + assert( + documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"), + "first canvas edit dismisses the guidance overlay", + ); + documentObject.querySelector("#undo-button").click(); + assertEqual( + selectedCoordinates(documentObject).join(","), + initialFirstPosition.join(","), + "one undo restores the whole drag", + ); + + dispatchDragPointer("pointerdown", 0, 0, 1); + dispatchDragPointer("pointermove", 28, -18, 1); + canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + })); + await nextPaint(windowObject); + assertEqual( + selectedCoordinates(documentObject).join(","), + initialFirstPosition.join(","), + "Escape cancels an in-progress drag", + ); + + const initialApertureWidth = documentObject.querySelector("#aperture-width").value; + dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0); + assertEqual( + documentObject.querySelector("#aperture-width").value, + initialApertureWidth, + "rejected aperture input is restored to the active value", + ); + assert( + documentObject.querySelector(".validation-card").classList.contains("is-invalid"), + "rejected action has an error visual state", + ); + + dispatchValue(windowObject, documentObject.querySelector("#preset-count"), ""); dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3); dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2); documentObject.querySelector("#generate-preset-button").click(); await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation"); + assertEqual( + documentObject.querySelector("#preset-count").value, + "", + "hidden count does not block grid generation", + ); assertEqual( microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","), "mic-001,mic-002,mic-003,mic-004,mic-005,mic-006", "grid keeps row-major physical channel IDs", ); + assertEqual( + microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length, + 1, + "microphone list has one roving Tab stop", + ); + microphoneButtons(documentObject)[0].focus(); + microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowDown", + })); + assertEqual( + documentObject.activeElement.dataset.microphoneId, + "mic-002", + "ArrowDown selects and focuses the next channel", + ); dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse"); dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring"); @@ -158,9 +263,14 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 "reference-channel", "manual ID edit", ); + documentObject.querySelector("#move-microphone-up-button").click(); + assertEqual( + microphoneButtons(documentObject).at(-2).dataset.microphoneId, + "reference-channel", + "channel forward button changes physical order", + ); const beforeNudge = selectedCoordinates(documentObject); - const canvas = documentObject.querySelector("#array-canvas"); canvas.focus(); canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, @@ -172,6 +282,80 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step"); assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y"); + const touchCanvasRect = canvas.getBoundingClientRect(); + const touchViewport = fitArrayViewport( + touchCanvasRect.width, + touchCanvasRect.height, + { + aperture: { + width: Number(documentObject.querySelector("#aperture-width").value), + height: Number(documentObject.querySelector("#aperture-height").value), + }, + microphones: [{ id: "selected", position: afterNudge }], + }, + ); + const selectedTouchPixel = arrayPositionToCanvas(afterNudge, touchViewport); + const dispatchTouchDragPointer = (type, pointerId, offsetX, offsetY, buttons) => + canvas.dispatchEvent(new windowObject.PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId, + pointerType: "touch", + isPrimary: true, + button: type === "pointermove" ? -1 : 0, + buttons, + clientX: touchCanvasRect.left + selectedTouchPixel.x + offsetX, + clientY: touchCanvasRect.top + selectedTouchPixel.y + offsetY, + })); + + dispatchTouchDragPointer("pointerdown", 22, 0, 0, 1); + const microphoneTouchStart = new windowObject.Event("touchstart", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(microphoneTouchStart, "changedTouches", { + value: [{ + clientX: touchCanvasRect.left + selectedTouchPixel.x, + clientY: touchCanvasRect.top + selectedTouchPixel.y, + }], + }); + canvas.dispatchEvent(microphoneTouchStart); + assert(microphoneTouchStart.defaultPrevented, "touch on a microphone reserves the drag gesture"); + dispatchTouchDragPointer("pointermove", 22, 3, 2, 1); + dispatchTouchDragPointer("pointerup", 22, 3, 2, 0); + await nextPaint(windowObject); + assertEqual( + selectedCoordinates(documentObject).join(","), + afterNudge.join(","), + "touch jitter below the gesture threshold does not move a microphone", + ); + + const blankTouchStart = new windowObject.Event("touchstart", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(blankTouchStart, "changedTouches", { + value: [{ clientX: touchCanvasRect.left + 3, clientY: touchCanvasRect.top + 3 }], + }); + canvas.dispatchEvent(blankTouchStart); + assert(!blankTouchStart.defaultPrevented, "blank canvas touch remains available to page scroll"); + + dispatchTouchDragPointer("pointerdown", 23, 0, 0, 1); + dispatchTouchDragPointer("pointermove", 23, 24, -18, 1); + dispatchTouchDragPointer("pointerup", 23, 24, -18, 0); + await nextPaint(windowObject); + const touchDraggedPosition = selectedCoordinates(documentObject); + assert( + touchDraggedPosition[0] !== afterNudge[0] && touchDraggedPosition[1] !== afterNudge[1], + "touch movement beyond the threshold drags both coordinates", + ); + documentObject.querySelector("#undo-button").click(); + assertEqual( + selectedCoordinates(documentObject).join(","), + afterNudge.join(","), + "one undo restores the whole touch drag", + ); + documentObject.querySelector("#add-tool-button").click(); const canvasRect = canvas.getBoundingClientRect(); canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", { @@ -190,6 +374,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 documentObject.querySelector("#remove-microphone-button").click(); await waitFor(() => microphoneCount(documentObject) === 13, "manual delete"); + documentObject.querySelector("#add-tool-button").click(); + const dispatchTouchAddPointer = (type, pointerId, offsetX, offsetY, buttons) => + canvas.dispatchEvent(new windowObject.PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId, + pointerType: "touch", + isPrimary: true, + button: type === "pointermove" ? -1 : 0, + buttons, + clientX: canvasRect.left + canvasRect.width * 0.7 + offsetX, + clientY: canvasRect.top + canvasRect.height * 0.35 + offsetY, + })); + assert( + dispatchTouchAddPointer("pointerdown", 32, 0, 0, 1), + "touch add pointerdown remains available to page scroll", + ); + assertEqual(microphoneCount(documentObject), 13, "touch add waits for pointerup"); + assert( + dispatchTouchAddPointer("pointermove", 32, 0, 12, 1), + "touch add scroll movement is not canceled", + ); + dispatchTouchAddPointer("pointerup", 32, 0, 12, 0); + await nextPaint(windowObject); + assertEqual(microphoneCount(documentObject), 13, "touch scroll intent does not add a microphone"); + + dispatchTouchAddPointer("pointerdown", 33, 0, 0, 1); + assertEqual(microphoneCount(documentObject), 13, "touch tap still waits while pressed"); + dispatchTouchAddPointer("pointermove", 33, 3, 2, 1); + assert( + !dispatchTouchAddPointer("pointerup", 33, 3, 2, 0), + "completed touch add tap prevents its synthetic click", + ); + await waitFor(() => microphoneCount(documentObject) === 14, "touch canvas microphone add"); + documentObject.querySelector("#select-tool-button").click(); + documentObject.querySelector("#remove-microphone-button").click(); + await waitFor(() => microphoneCount(documentObject) === 13, "touch-added microphone delete"); + const firstButton = microphoneButtons(documentObject)[0]; const secondButton = microphoneButtons(documentObject)[1]; firstButton.click(); @@ -228,6 +450,20 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 assert(calibrated, "edited channel is exported"); assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved"); assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved"); + assertEqual( + exported.microphones.indexOf(calibrated), + exported.microphones.length - 2, + "export keeps the edited physical channel order", + ); + calibrated.frequencyResponse = [ + { frequencyHz: 100, gainDb: -2, phaseDeg: -4 }, + { frequencyHz: 1000, gainDb: 0.5, phaseDeg: 3 }, + ]; + assertEqual( + (await validateArrayDefinition(exported)).length, + 0, + "response-enhanced round trip fixture contract", + ); dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name"); const exportedFile = new windowObject.File( @@ -240,6 +476,27 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 () => documentObject.querySelector("#array-name").value === exported.name, "Array round trip import", ); + assert(!documentObject.querySelector("#undo-button").disabled, "valid import is undoable"); + documentObject.querySelector("#undo-button").click(); + assertEqual( + documentObject.querySelector("#array-name").value, + "Temporary name", + "import undo restores the previous document", + ); + assert( + documentObject.querySelector("#save-state").classList.contains("is-dirty"), + "document before imported baseline is dirty", + ); + documentObject.querySelector("#redo-button").click(); + assertEqual( + documentObject.querySelector("#array-name").value, + exported.name, + "import redo restores the loaded document", + ); + assert( + !documentObject.querySelector("#save-state").classList.contains("is-dirty"), + "loaded baseline is clean after redo", + ); microphoneButtons(documentObject).find( (button) => button.dataset.microphoneId === "reference-channel", ).click(); @@ -248,6 +505,62 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 -2.5, "import keeps sensitivity", ); + assert( + documentObject.querySelector("#frequency-response-summary").textContent.includes("2개"), + "import exposes preserved frequency-response points", + ); + const importedReferenceX = Number(documentObject.querySelector("#microphone-x").value); + dispatchValue( + windowObject, + documentObject.querySelector("#microphone-x"), + importedReferenceX + 0.001, + ); + documentObject.querySelector("#export-button").click(); + await waitFor( + () => downloads.filter((item) => item.fileName).length === 2, + "edited response Array export", + ); + const editedExport = JSON.parse( + await downloads.filter((item) => item.fileName).at(-1).blob.text(), + ); + assertEqual( + JSON.stringify( + editedExport.microphones.find( + (microphone) => microphone.id === "reference-channel", + ).frequencyResponse, + ), + JSON.stringify(calibrated.frequencyResponse), + "position edit preserves every frequency-response value", + ); + + const responseVariant = structuredClone(editedExport); + responseVariant.microphones.find( + (microphone) => microphone.id === "reference-channel", + ).frequencyResponse[0].gainDb -= 1; + const responseVariantFile = new windowObject.File( + [JSON.stringify(responseVariant)], + "response-variant.array.json", + { type: "application/json" }, + ); + dispatchFiles( + windowObject, + documentObject.querySelector("#array-input"), + [responseVariantFile], + ); + await waitFor( + () => documentObject.querySelector("#save-state").textContent.includes("불러옴"), + "same-length response variant import", + ); + documentObject.querySelector("#undo-button").click(); + assert( + documentObject.querySelector("#save-state").classList.contains("is-dirty"), + "undo detects changed response values even when the point count is unchanged", + ); + documentObject.querySelector("#redo-button").click(); + assert( + !documentObject.querySelector("#save-state").classList.contains("is-dirty"), + "response variant baseline is clean after redo", + ); const countBeforeInvalidImport = microphoneCount(documentObject); const invalidFile = new windowObject.File( @@ -266,12 +579,109 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 "invalid import preserves current array", ); + const cameraFrame = document.createElement("iframe"); + cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1"; + cameraFrame.style.cssText = + "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; + document.body.append(cameraFrame); + await new Promise((resolve, reject) => { + cameraFrame.addEventListener("load", resolve, { once: true }); + cameraFrame.addEventListener( + "error", + () => reject(new Error("Camera handoff iframe load failed")), + { once: true }, + ); + }); + const cameraWindow = cameraFrame.contentWindow; + const cameraDocument = cameraFrame.contentDocument; + const cameraDownloads = []; + const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL); + const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL); + const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click; + cameraWindow.URL.createObjectURL = (blob) => { + const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1); + cameraDownloads.push({ blob, url, fileName: null }); + return url; + }; + cameraWindow.URL.revokeObjectURL = () => {}; + cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() { + const download = cameraDownloads.find((item) => item.url === this.href); + if (download) download.fileName = this.download; + }; + try { + await waitFor( + () => cameraDocument.documentElement.dataset.appReady === "true", + "Camera handoff initialization", + ); + const handoffFile = new cameraWindow.File( + [JSON.stringify(editedExport)], + "builder-handoff.array.json", + { type: "application/json" }, + ); + dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]); + await waitFor( + () => cameraDocument.querySelector("#array-preset").value === "custom" && + cameraDocument.querySelector("#microphone-count").textContent.includes( + String(editedExport.microphones.length), + ) && + cameraDocument.documentElement.dataset.cameraValid === "true", + "Camera custom Array acceptance", + ); + assertEqual( + cameraDocument.querySelector("#array-title").textContent, + editedExport.name, + "Camera shows the Builder array name", + ); + cameraDocument.querySelector("#export-experiment-button").click(); + await waitFor( + () => cameraDownloads.some((item) => item.fileName), + "Camera Experiment export after handoff", + ); + const cameraExperiment = JSON.parse( + await cameraDownloads.find((item) => item.fileName).blob.text(), + ); + assertEqual( + cameraExperiment.arrayId, + editedExport.id, + "Camera rebinds Experiment to the Builder Array ID", + ); + } finally { + cameraWindow.URL.createObjectURL = originalCameraCreate; + cameraWindow.URL.revokeObjectURL = originalCameraRevoke; + cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick; + cameraFrame.remove(); + } + frame.style.width = "390px"; await waitFor( () => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false", "compact Array Builder layout", ); await nextPaint(windowObject); + const compactWorkspace = documentObject.querySelector("#array-builder-workspace"); + const compactControls = documentObject.querySelector("#array-builder-controls"); + const compactWorkspaceRect = compactWorkspace.getBoundingClientRect(); + const compactControlsRect = compactControls.getBoundingClientRect(); + assert( + Boolean( + compactWorkspace.compareDocumentPosition(compactControls) & + windowObject.Node.DOCUMENT_POSITION_FOLLOWING, + ), + "compact canvas workspace also precedes settings in DOM order", + ); + assert( + compactWorkspaceRect.bottom <= compactControlsRect.top + 1, + "compact canvas workspace appears before the long settings form", + ); + assert( + documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top < + documentObject.querySelector(".aperture-section").getBoundingClientRect().top && + documentObject.querySelector(".aperture-section").getBoundingClientRect().top < + documentObject.querySelector(".preset-section").getBoundingClientRect().top && + documentObject.querySelector(".preset-section").getBoundingClientRect().top < + documentObject.querySelector(".microphone-section").getBoundingClientRect().top, + "compact controls keep the numbered source and visual order", + ); assert( documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, @@ -281,6 +691,16 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, "Array canvas remains usable at 390px", ); + assertEqual( + windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction, + "pan-y", + "mobile canvas permits vertical page scrolling", + ); + assert( + windowObject.getComputedStyle(documentObject.querySelector("#mobile-export-button")).display + !== "none", + "compact settings end with a visible save action", + ); } finally { windowObject.URL.createObjectURL = originalCreate; windowObject.URL.revokeObjectURL = originalRevoke; diff --git a/tests/integration/split-layout-ui.test.js b/tests/integration/split-layout-ui.test.js index c206c6c..e138a4f 100644 --- a/tests/integration/split-layout-ui.test.js +++ b/tests/integration/split-layout-ui.test.js @@ -51,6 +51,7 @@ const LAYOUT_CASES = [ defaultGridTracks: 2, homePanelWidth: 360, homeGridTracks: 1, + compactWorkspaceFirst: true, }, ]; @@ -444,7 +445,12 @@ async function exerciseSplitLayout(definition) { const panelRect = mounted.panel.getBoundingClientRect(); const workspaceRect = mounted.workspace.getBoundingClientRect(); - if (definition.panelSide === "start") { + if (definition.compactWorkspaceFirst) { + assert( + panelRect.top >= workspaceRect.bottom - 1, + definition.label + " compact workspace precedes the long settings panel", + ); + } else if (definition.panelSide === "start") { assert( workspaceRect.top >= panelRect.bottom - 1, definition.label + " compact workspace follows the panel vertically", diff --git a/tests/unit/README.md b/tests/unit/README.md index 7ab6c6c..7dc8311 100644 --- a/tests/unit/README.md +++ b/tests/unit/README.md @@ -28,11 +28,16 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩 - `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성, bounded ring/result batch, 취소와 geometry·steering cache 연산 절감 - `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증 +- `array-builder.test.js`: Array 문서 모델, 격자·선형·둘레·Fermat·seed random 프리셋, + 최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단 +- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test, + 1/2/5 grid와 keyboard nudge - `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse - `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증 - `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching, 장치 한계와 typed CPU fallback 정책 +- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치 WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는 integration test에서 adapter가 있을 때 수행합니다. diff --git a/tests/unit/array-builder-canvas.test.js b/tests/unit/array-builder-canvas.test.js index d882135..16b11c3 100644 --- a/tests/unit/array-builder-canvas.test.js +++ b/tests/unit/array-builder-canvas.test.js @@ -86,6 +86,47 @@ test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채 assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss"); }); +test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => { + const viewport = { + plot: { x: 0, y: 0 }, + xMin: 0, + yMax: 100, + scale: 2, + }; + const microphones = [ + { id: "overflow", position: [Number.MAX_VALUE, 0, 0] }, + { id: "normal", position: [5, 5, 0] }, + ]; + assertEqual( + arrayPositionToCanvas(microphones[0].position, viewport).x, + Infinity, + "fixture overflows after viewport conversion", + ); + assertEqual( + hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id, + "normal", + "finite microphone remains hittable", + ); +}); + +test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => { + const arrayDefinition = fixture(); + arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0]; + arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0]; + const viewport = fitArrayViewport(800, 420, arrayDefinition); + for (const [name, value] of Object.entries({ + scale: viewport.scale, + metersPerPixel: viewport.metersPerPixel, + xMin: viewport.xMin, + xMax: viewport.xMax, + yMin: viewport.yMin, + yMax: viewport.yMax, + })) { + assert(Number.isFinite(value), name + " remains finite"); + } + assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin"); +}); + test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => { const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)]; for (const step of candidates) { diff --git a/tests/unit/array-builder.test.js b/tests/unit/array-builder.test.js index 6f9a4d7..7624e01 100644 --- a/tests/unit/array-builder.test.js +++ b/tests/unit/array-builder.test.js @@ -2,6 +2,7 @@ import { MAX_MICROPHONES, addMicrophone, arrayGeometryIssues, + arrayStatistics, centerMicrophones, clampPositionToAperture, createArrayDefinition, @@ -9,6 +10,7 @@ import { generatePresetMicrophones, inferApertureShape, moveMicrophone, + nextMicrophoneId, positionInsideAperture, removeMicrophone, replaceMicrophonesWithPreset, @@ -110,6 +112,45 @@ test("seed random preset은 재현되고 최소 간격과 형상 경계를 지 } }); +test("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => { + const startedAt = performance.now(); + const microphones = generatePresetMicrophones({ + type: "random", + aperture: { width: 0.45, height: 0.45 }, + shape: "rectangle", + count: 1024, + seed: 20260828, + minimumSpacingM: 0.002, + }); + const elapsedMs = performance.now() - startedAt; + assertEqual(microphones.length, 1024, "maximum random channel count"); + assertEqual( + new Set(microphones.map((microphone) => microphone.position.join(","))).size, + 1024, + "maximum random positions are unique", + ); + assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms"); + + const failedAt = performance.now(); + assertThrows( + () => generatePresetMicrophones({ + type: "random", + aperture: { width: 0.45, height: 0.45 }, + shape: "rectangle", + count: 1024, + seed: 7, + minimumSpacingM: 0.03, + }), + "배치할 수 없습니다", + "dense maximum random failure", + ); + const failedElapsedMs = performance.now() - failedAt; + assert( + failedElapsedMs < 3000, + "impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms", + ); +}); + test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => { const arrayDefinition = createArrayDefinition(); const before = JSON.stringify(arrayDefinition.microphones); @@ -185,7 +226,7 @@ test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작 assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height"); }); -test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => { +test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => { const spiral = createArrayDefinition({ description: "Fermat spiral on circular aperture", presetType: "spiral", @@ -194,4 +235,42 @@ test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한 assertEqual(inferApertureShape(spiral), "ellipse", "description inference"); const grid = createArrayDefinition(); assertEqual(inferApertureShape(grid), "rectangle", "grid inference"); + grid.description = "Spring-mounted rectangular array"; + assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive"); +}); + +test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => { + const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); + arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0]; + arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0]; + const statistics = arrayStatistics(arrayDefinition); + assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite"); + assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite"); + assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid"); + assertEqual(statistics.centroid[1], 0, "balanced y centroid"); +}); + +test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => { + const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 }); + arrayDefinition.microphones[0].id = "mic-9007199254740992"; + assertEqual( + nextMicrophoneId(arrayDefinition), + "mic-9007199254740993", + "BigInt suffix increment", + ); + assertEqual( + addMicrophone(arrayDefinition, [0.01, 0, 0]).id, + "mic-9007199254740993", + "manual add uses the safe ID", + ); +}); + +test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => { + const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); + arrayDefinition.microphones[0].position = [0.0000000001, 0, 0]; + arrayDefinition.microphones[1].position = [0.0000000002, 0, 0]; + assert( + !arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"), + "distinct high-precision positions", + ); });