From c289af366cde94fb7204f3f6e61e1f2c3ff5d57c Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Sun, 30 Aug 2026 10:35:27 +0900 Subject: [PATCH] feat: add live multi-plane and 3d array views --- .../beam-pattern-surface-view.js | 429 +++++++++++++++ apps/array-builder/canvas-view.js | 438 +++++++++++++++- apps/array-builder/index.html | 126 +++-- apps/array-builder/main.js | 491 ++++++++++++++---- apps/array-builder/styles.css | 225 +++++++- tests/integration/array-builder-ui.test.js | 359 +++++++++++-- tests/unit/array-builder-canvas.test.js | 27 + 7 files changed, 1895 insertions(+), 200 deletions(-) create mode 100644 apps/array-builder/beam-pattern-surface-view.js diff --git a/apps/array-builder/beam-pattern-surface-view.js b/apps/array-builder/beam-pattern-surface-view.js new file mode 100644 index 0000000..845da76 --- /dev/null +++ b/apps/array-builder/beam-pattern-surface-view.js @@ -0,0 +1,429 @@ +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; + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function numericSequence(value) { + return Array.isArray(value) || ArrayBuffer.isView(value); +} + +function formatFrequency(frequencyHz) { + if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz"; + if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz"; + return frequencyHz.toFixed(1) + " Hz"; +} + +function surfaceColor(level, alpha = 0.72) { + const normalized = clamp(level, 0, 1); + const hue = 198 - normalized * 48; + const saturation = 58 + normalized * 24; + const lightness = 18 + normalized * 48; + return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; +} + +function validateSurface(surface) { + if (!surface || typeof surface !== "object") { + throw new TypeError("3D 빔 패턴 결과가 없습니다."); + } + const azimuthAxisDeg = surface.azimuthAxisDeg; + const elevationAxisDeg = surface.elevationAxisDeg; + const displayDb = surface.displayDb; + if ( + !numericSequence(azimuthAxisDeg) || + !numericSequence(elevationAxisDeg) || + !numericSequence(displayDb) + ) { + throw new TypeError("3D 빔 패턴 축과 displayDb는 수치 배열이어야 합니다."); + } + const rows = Number(surface.gridShape?.[0]); + const columns = Number(surface.gridShape?.[1]); + if ( + !Number.isInteger(rows) || + !Number.isInteger(columns) || + rows !== elevationAxisDeg.length || + columns !== azimuthAxisDeg.length || + rows < 2 || + columns < 3 || + displayDb.length !== rows * columns + ) { + throw new RangeError("3D 빔 패턴 grid shape과 표본 수가 일치하지 않습니다."); + } + if (rows * columns > MAX_SURFACE_VIEW_SAMPLES) { + throw new RangeError( + "3D 표시 표본은 " + MAX_SURFACE_VIEW_SAMPLES.toLocaleString("en-US") + + "개를 넘을 수 없습니다.", + ); + } + const dbFloor = Number(surface.dbFloor); + if (!Number.isFinite(dbFloor) || dbFloor >= 0) { + throw new RangeError("3D 빔 패턴 dbFloor는 0보다 작은 유한한 값이어야 합니다."); + } + for (const value of displayDb) { + if (!Number.isFinite(Number(value))) { + throw new RangeError("3D 빔 패턴 displayDb에는 유한한 값만 사용할 수 있습니다."); + } + } + return { azimuthAxisDeg, elevationAxisDeg, displayDb, rows, columns, dbFloor }; +} + +function prepareCanvas(canvas, context, view) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, rect.width || canvas.clientWidth || 720); + const height = Math.max(1, rect.height || canvas.clientHeight || width * 0.625); + const ratio = Math.min(MAX_PIXEL_RATIO, Math.max(1, Number(view.devicePixelRatio) || 1)); + const pixelWidth = Math.max(1, Math.round(width * ratio)); + const pixelHeight = Math.max(1, Math.round(height * ratio)); + if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { + canvas.width = pixelWidth; + canvas.height = pixelHeight; + } + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, width, height); + return { width, height }; +} + +function unitDirection(azimuthDeg, elevationDeg) { + const azimuth = Number(azimuthDeg) * Math.PI / 180; + const elevation = Number(elevationDeg) * Math.PI / 180; + const horizontal = Math.cos(elevation); + return [ + horizontal * Math.cos(azimuth), + horizontal * Math.sin(azimuth), + Math.sin(elevation), + ]; +} + +function rotatedPoint(point, yaw, pitch) { + const cosYaw = Math.cos(yaw); + const sinYaw = Math.sin(yaw); + const xYaw = cosYaw * point[0] - sinYaw * point[1]; + const yYaw = sinYaw * point[0] + cosYaw * point[1]; + const cosPitch = Math.cos(pitch); + const sinPitch = Math.sin(pitch); + return [ + xYaw, + point[2] * cosPitch + yYaw * sinPitch, + point[2] * sinPitch - yYaw * cosPitch, + ]; +} + +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, + depth: rotated[2], + }; +} + +function polygonArea(points) { + let area = 0; + for (let index = 0; index < points.length; index += 1) { + const current = points[index]; + const next = points[(index + 1) % points.length]; + area += current.x * next.y - next.x * current.y; + } + return Math.abs(area) / 2; +} + +export function createReceiveBeamPatternSurfaceView(canvas) { + if (!canvas?.getContext) { + throw new TypeError("3D 빔 패턴 canvas가 필요합니다."); + } + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("3D 빔 패턴 2D drawing context를 만들 수 없습니다."); + const view = canvas.ownerDocument?.defaultView ?? globalThis; + const requestFrame = view.requestAnimationFrame?.bind(view) ?? + ((callback) => view.setTimeout(callback, 16)); + const cancelFrame = view.cancelAnimationFrame?.bind(view) ?? view.clearTimeout?.bind(view); + let surface = null; + let validation = null; + let errorMessage = ""; + let yaw = DEFAULT_YAW_RAD; + let pitch = DEFAULT_PITCH_RAD; + let orbit = null; + let frameId = 0; + let destroyed = false; + + function schedule() { + if (!destroyed && !frameId) frameId = requestFrame(draw); + } + + function drawMessage(size, message) { + context.fillStyle = "#07110f"; + context.fillRect(0, 0, size.width, size.height); + context.fillStyle = "#ffc1bd"; + context.font = "650 13px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(message, size.width / 2, size.height / 2); + } + + function drawAxis(size, vector, color, label) { + const start = projectedPoint([0, 0, 0], yaw, pitch, size); + const end = projectedPoint(vector.map((value) => value * 1.18), yaw, pitch, size); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 1.6; + context.beginPath(); + context.moveTo(start.x, start.y); + context.lineTo(end.x, end.y); + context.stroke(); + context.font = "750 11px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(label, end.x, end.y - 10); + } + + function draw() { + frameId = 0; + if (destroyed) return; + const size = prepareCanvas(canvas, context, view); + context.fillStyle = "#07110f"; + context.fillRect(0, 0, size.width, size.height); + if (errorMessage) { + drawMessage(size, errorMessage); + return; + } + if (!surface || !validation) { + drawMessage(size, "3D 빔 패턴을 계산하고 있습니다."); + return; + } + + const { azimuthAxisDeg, elevationAxisDeg, displayDb, rows, columns, dbFloor } = + validation; + const vertices = new Array(rows * columns); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const index = row * columns + column; + const level = clamp((Number(displayDb[index]) - dbFloor) / -dbFloor, 0, 1); + const direction = unitDirection(azimuthAxisDeg[column], elevationAxisDeg[row]); + const point = direction.map((value) => value * level); + vertices[index] = { ...projectedPoint(point, yaw, pitch, size), level }; + } + } + + const faces = []; + for (let row = 0; row < rows - 1; row += 1) { + for (let column = 0; column < columns; column += 1) { + const nextColumn = (column + 1) % columns; + const points = [ + vertices[row * columns + column], + vertices[row * columns + nextColumn], + vertices[(row + 1) * columns + nextColumn], + vertices[(row + 1) * columns + column], + ]; + if (polygonArea(points) < 0.08) continue; + faces.push({ + points, + depth: points.reduce((sum, point) => sum + point.depth, 0) / points.length, + level: points.reduce((sum, point) => sum + point.level, 0) / points.length, + }); + } + } + faces.sort((left, right) => left.depth - right.depth); + for (const face of faces) { + context.beginPath(); + context.moveTo(face.points[0].x, face.points[0].y); + for (let index = 1; index < face.points.length; index += 1) { + context.lineTo(face.points[index].x, face.points[index].y); + } + context.closePath(); + context.fillStyle = surfaceColor(face.level); + context.fill(); + context.strokeStyle = "rgba(218,245,236,.075)"; + context.lineWidth = 0.55; + context.stroke(); + } + + const steeringDirection = Array.isArray(surface.steeringDirection) + ? surface.steeringDirection + : unitDirection(surface.steeringAzimuthDeg ?? 0, surface.steeringElevationDeg ?? 90); + const origin = projectedPoint([0, 0, 0], yaw, pitch, size); + const steeringEnd = projectedPoint( + steeringDirection.map((value) => Number(value) * 1.12), + yaw, + pitch, + size, + ); + context.save(); + context.setLineDash([7, 5]); + context.strokeStyle = "#ffc56e"; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(origin.x, origin.y); + context.lineTo(steeringEnd.x, steeringEnd.y); + context.stroke(); + context.restore(); + + drawAxis(size, [1, 0, 0], "#ff8f85", "+X"); + drawAxis(size, [0, 1, 0], "#58c7e8", "+Y"); + drawAxis(size, [0, 0, 1], "#65f2bf", "+Z FRONT"); + + context.fillStyle = "rgba(7,17,15,.82)"; + context.fillRect(12, 12, 190, 42); + context.fillStyle = "#dceee8"; + context.font = "700 12px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.textAlign = "left"; + context.textBaseline = "top"; + context.fillText(formatFrequency(Number(surface.frequencyHz)), 20, 20); + context.fillStyle = "#8eaaa0"; + context.font = "600 10px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.fillText(columns + "×" + rows + " raw sphere samples", 20, 38); + + const legendX = 16; + const legendY = size.height - 26; + const legendWidth = Math.min(170, size.width * 0.3); + for (let index = 0; index < 32; index += 1) { + const level = index / 31; + context.fillStyle = surfaceColor(level, 1); + context.fillRect(legendX + legendWidth * level, legendY, legendWidth / 31 + 1, 7); + } + context.fillStyle = "#9bb4ab"; + context.font = "600 9px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.textBaseline = "bottom"; + context.fillText(String(dbFloor) + " dB", legendX, legendY - 2); + context.textAlign = "right"; + context.fillText("0 dB", legendX + legendWidth, legendY - 2); + } + + function render(nextSurface) { + if (destroyed) return; + try { + validation = validateSurface(nextSurface); + surface = nextSurface; + errorMessage = ""; + canvas.dataset.sampleCount = String(validation.rows * validation.columns); + canvas.dataset.frequencyHz = String(nextSurface.frequencyHz); + canvas.dataset.gridShape = validation.columns + "x" + validation.rows; + } catch (error) { + surface = null; + validation = null; + delete canvas.dataset.sampleCount; + delete canvas.dataset.frequencyHz; + delete canvas.dataset.gridShape; + errorMessage = error instanceof Error ? error.message : String(error); + } + schedule(); + } + + function renderError(message) { + surface = null; + validation = null; + delete canvas.dataset.sampleCount; + delete canvas.dataset.frequencyHz; + delete canvas.dataset.gridShape; + errorMessage = String(message || "3D 빔 패턴을 표시할 수 없습니다."); + schedule(); + } + + function finishOrbit(event) { + if (!orbit || (event?.pointerId !== undefined && event.pointerId !== orbit.pointerId)) return; + const pointerId = orbit.pointerId; + orbit = null; + delete canvas.dataset.orbiting; + try { + if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId); + } catch { + // Pointer capture may already be released. + } + } + + function onPointerDown(event) { + if (event.button !== 0 || orbit) return; + event.preventDefault(); + try { + canvas.focus({ preventScroll: true }); + } catch { + canvas.focus?.(); + } + orbit = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + canvas.dataset.orbiting = "true"; + try { + canvas.setPointerCapture?.(event.pointerId); + } catch { + // Synthetic pointers may not own capture. + } + } + + 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, + -MAX_PITCH_RAD, + MAX_PITCH_RAD, + ); + orbit.x = event.clientX; + orbit.y = event.clientY; + schedule(); + } + + 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; + else if (event.key === "ArrowUp") { + pitch = clamp(pitch + step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD); + } else if (event.key === "ArrowDown") { + pitch = clamp(pitch - step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD); + } + else if (event.key.toLowerCase() === "r") { + yaw = DEFAULT_YAW_RAD; + pitch = DEFAULT_PITCH_RAD; + } else return; + event.preventDefault(); + schedule(); + } + + canvas.setAttribute("aria-keyshortcuts", "ArrowLeft ArrowRight ArrowUp ArrowDown R"); + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", finishOrbit); + canvas.addEventListener("pointercancel", finishOrbit); + canvas.addEventListener("lostpointercapture", finishOrbit); + canvas.addEventListener("keydown", onKeyDown); + let resizeObserver = null; + const onResize = () => schedule(); + if (typeof view.ResizeObserver === "function") { + resizeObserver = new view.ResizeObserver(onResize); + resizeObserver.observe(canvas); + } else { + view.addEventListener?.("resize", onResize); + } + schedule(); + + return { + render, + renderError, + resetOrbit() { + yaw = DEFAULT_YAW_RAD; + pitch = DEFAULT_PITCH_RAD; + schedule(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (frameId) cancelFrame?.(frameId); + resizeObserver?.disconnect(); + view.removeEventListener?.("resize", onResize); + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", finishOrbit); + canvas.removeEventListener("pointercancel", finishOrbit); + canvas.removeEventListener("lostpointercapture", finishOrbit); + canvas.removeEventListener("keydown", onKeyDown); + surface = null; + validation = null; + orbit = null; + }, + }; +} diff --git a/apps/array-builder/canvas-view.js b/apps/array-builder/canvas-view.js index 870a4d3..521d3c2 100644 --- a/apps/array-builder/canvas-view.js +++ b/apps/array-builder/canvas-view.js @@ -7,6 +7,7 @@ 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 }); +const DEFAULT_ORBIT = Object.freeze({ yaw: -Math.PI / 5, pitch: Math.PI / 4.5 }); export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]); @@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) { function validPosition(position) { return Array.isArray(position) && + position.length >= 3 && Number.isFinite(Number(position[0])) && - Number.isFinite(Number(position[1])); + Number.isFinite(Number(position[1])) && + Number.isFinite(Number(position[2])); } function apertureDimensions(arrayDefinition) { @@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) { return best; } +function rotateArrayPosition3d(position, yaw, pitch) { + const x = finiteNumber(position?.[0]); + const y = finiteNumber(position?.[1]); + const z = finiteNumber(position?.[2]); + const cosYaw = Math.cos(yaw); + const sinYaw = Math.sin(yaw); + const cosPitch = Math.cos(pitch); + const sinPitch = Math.sin(pitch); + const horizontal = cosYaw * x - sinYaw * y; + const away = sinYaw * x + cosYaw * y; + return { + horizontal, + vertical: z * cosPitch + away * sinPitch, + depth: z * sinPitch - away * cosPitch, + }; +} + +/** Project an array [x,y,z] position into a fitted 3D inspection viewport. */ +export function projectArrayPosition3d(position, viewport) { + const rotated = rotateArrayPosition3d(position, viewport.yaw, viewport.pitch); + return { + x: viewport.centerPixelX + (rotated.horizontal - viewport.centerHorizontal) * viewport.scale, + y: viewport.centerPixelY - (rotated.vertical - viewport.centerVertical) * viewport.scale, + depth: rotated.depth, + }; +} + +function fitArrayViewport3d(width, height, model, zoom, orbit) { + const canvasWidth = Math.max(1, finiteNumber(width, 1)); + const canvasHeight = Math.max(1, finiteNumber(height, 1)); + const margins = resolvedMargins(canvasWidth, canvasHeight, { + left: 32, + right: 32, + top: 45, + bottom: 38, + }); + const plot = { + x: margins.left, + y: margins.top, + width: Math.max(1, canvasWidth - margins.left - margins.right), + height: Math.max(1, canvasHeight - margins.top - margins.bottom), + }; + const aperture = apertureDimensions(model.arrayDefinition); + const referenceSpan = Math.max(aperture.width, aperture.height, 0.000001); + const points = [ + [-aperture.width / 2, -aperture.height / 2, 0], + [aperture.width / 2, -aperture.height / 2, 0], + [aperture.width / 2, aperture.height / 2, 0], + [-aperture.width / 2, aperture.height / 2, 0], + [0, 0, referenceSpan * 0.72], + ]; + for (const microphone of model.arrayDefinition.microphones ?? []) { + if (validPosition(microphone?.position)) points.push(microphone.position); + } + const rotated = points.map((position) => rotateArrayPosition3d(position, orbit.yaw, orbit.pitch)); + const horizontalValues = rotated.map((point) => point.horizontal); + const verticalValues = rotated.map((point) => point.vertical); + const horizontalMin = Math.min(...horizontalValues); + const horizontalMax = Math.max(...horizontalValues); + const verticalMin = Math.min(...verticalValues); + const verticalMax = Math.max(...verticalValues); + const horizontalSpan = Math.max(0.000001, horizontalMax - horizontalMin); + const verticalSpan = Math.max(0.000001, verticalMax - verticalMin); + const scale = Math.max( + Number.MIN_VALUE, + Math.min(plot.width / (horizontalSpan * 1.22), plot.height / (verticalSpan * 1.22)) * zoom, + ); + return { + width: canvasWidth, + height: canvasHeight, + plot, + scale, + zoom, + yaw: orbit.yaw, + pitch: orbit.pitch, + centerHorizontal: (horizontalMin + horizontalMax) / 2, + centerVertical: (verticalMin + verticalMax) / 2, + centerPixelX: plot.x + plot.width / 2, + centerPixelY: plot.y + plot.height / 2, + referenceSpan, + }; +} + /** Pure keyboard-to-array delta mapping used by the canvas controller. */ export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) { const requestedStep = Math.abs(finiteNumber(stepM, 0.001)); @@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) { context.textBaseline = "top"; context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8); context.textAlign = "left"; + context.textBaseline = "top"; + context.fillStyle = "rgba(101,242,191,.88)"; + context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.beginPath(); + context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 5, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 1.8, 0, Math.PI * 2); + context.fill(); + context.fillText("+Z FRONT", viewport.plot.x + 20, viewport.plot.y + 5); + context.textAlign = "left"; return viewport; } +function traceProjectedPositions(context, positions, viewport, close = false) { + context.beginPath(); + positions.forEach((position, index) => { + const point = projectArrayPosition3d(position, viewport); + if (index === 0) context.moveTo(point.x, point.y); + else context.lineTo(point.x, point.y); + }); + if (close) context.closePath(); +} + +function aperturePerimeter3d(model, sampleCount = 72) { + const aperture = apertureDimensions(model.arrayDefinition); + if (model.apertureShape !== "ellipse") { + return [ + [-aperture.width / 2, -aperture.height / 2, 0], + [aperture.width / 2, -aperture.height / 2, 0], + [aperture.width / 2, aperture.height / 2, 0], + [-aperture.width / 2, aperture.height / 2, 0], + ]; + } + return Array.from({ length: sampleCount }, (_, index) => { + const angle = index / sampleCount * Math.PI * 2; + return [ + Math.cos(angle) * aperture.width / 2, + Math.sin(angle) * aperture.height / 2, + 0, + ]; + }); +} + +function drawProjectedLine(context, start, end, viewport) { + const a = projectArrayPosition3d(start, viewport); + const b = projectArrayPosition3d(end, viewport); + context.beginPath(); + context.moveTo(a.x, a.y); + context.lineTo(b.x, b.y); + context.stroke(); + return b; +} + +function drawArrayPlane3d(context, model, viewport) { + const perimeter = aperturePerimeter3d(model); + traceProjectedPositions(context, perimeter, viewport, true); + context.fillStyle = "rgba(101,242,191,.055)"; + context.fill(); + context.strokeStyle = "rgba(174,229,210,.72)"; + context.lineWidth = 1.5; + context.setLineDash([7, 5]); + context.stroke(); + context.setLineDash([]); + + const aperture = apertureDimensions(model.arrayDefinition); + context.strokeStyle = "rgba(166,196,186,.14)"; + context.lineWidth = 1; + for (const fraction of [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]) { + if (model.apertureShape === "ellipse") { + const bound = Math.sqrt(Math.max(0, 1 - fraction * fraction)); + drawProjectedLine( + context, + [fraction * aperture.width / 2, -bound * aperture.height / 2, 0], + [fraction * aperture.width / 2, bound * aperture.height / 2, 0], + viewport, + ); + drawProjectedLine( + context, + [-bound * aperture.width / 2, fraction * aperture.height / 2, 0], + [bound * aperture.width / 2, fraction * aperture.height / 2, 0], + viewport, + ); + } else { + drawProjectedLine( + context, + [fraction * aperture.width / 2, -aperture.height / 2, 0], + [fraction * aperture.width / 2, aperture.height / 2, 0], + viewport, + ); + drawProjectedLine( + context, + [-aperture.width / 2, fraction * aperture.height / 2, 0], + [aperture.width / 2, fraction * aperture.height / 2, 0], + viewport, + ); + } + } +} + +function drawAxis3d(context, viewport, endpoint, color, label) { + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 2; + const point = drawProjectedLine(context, [0, 0, 0], endpoint, viewport); + context.beginPath(); + context.arc(point.x, point.y, 3, 0, Math.PI * 2); + context.fill(); + context.font = "800 10px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.textAlign = "left"; + context.textBaseline = "middle"; + context.fillText(label, point.x + 6, point.y); +} + +function drawAxes3d(context, model, viewport) { + const aperture = apertureDimensions(model.arrayDefinition); + drawAxis3d(context, viewport, [aperture.width * 0.58, 0, 0], "#ff9e75", "+X"); + drawAxis3d(context, viewport, [0, aperture.height * 0.58, 0], "#76b9ff", "+Y"); + drawAxis3d( + context, + viewport, + [0, 0, viewport.referenceSpan * 0.72], + "#65f2bf", + "+Z FRONT", + ); +} + +function projectedMicrophones(model, viewport) { + return (model.arrayDefinition.microphones ?? []) + .filter((microphone) => validPosition(microphone?.position)) + .map((microphone, index) => ({ + microphone, + index, + point: projectArrayPosition3d(microphone.position, viewport), + })) + .sort((left, right) => left.point.depth - right.point.depth); +} + +function drawMicrophones3d(context, model, viewport) { + const microphones = projectedMicrophones(model, viewport); + const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7); + let selected = null; + for (const item of microphones) { + const { microphone, point } = item; + const isSelected = microphone.id === model.selectedMicrophoneId; + const inside = positionInsideAperture( + microphone.position, + model.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 : microphones.length > 256 ? 0 : 6; + context.beginPath(); + context.arc(point.x, point.y, radius, 0, Math.PI * 2); + context.fill(); + context.shadowBlur = 0; + context.strokeStyle = "rgba(239,255,249,.7)"; + context.lineWidth = 1; + context.stroke(); + if (isSelected) selected = { microphone, point, radius }; + } + if (selected) { + context.strokeStyle = "rgba(255,255,255,.94)"; + context.lineWidth = 2; + context.beginPath(); + context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2); + context.stroke(); + drawSelectedLabel(context, selected.microphone, selected.point, viewport); + } + return microphones; +} + +function drawArray3d(context, size, model, zoom, orbit) { + context.fillStyle = BACKGROUND; + context.fillRect(0, 0, size.width, size.height); + if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) { + drawEmpty(context, size.width, size.height); + return null; + } + const viewport = fitArrayViewport3d(size.width, size.height, model, zoom, orbit); + context.fillStyle = PLOT_BACKGROUND; + context.fillRect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height); + context.save(); + context.beginPath(); + context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height); + context.clip(); + drawArrayPlane3d(context, model, viewport); + drawAxes3d(context, model, viewport); + viewport.projectedMicrophones = drawMicrophones3d(context, model, viewport); + context.restore(); + + context.strokeStyle = "rgba(174,211,199,.28)"; + context.lineWidth = 1; + context.strokeRect( + viewport.plot.x + 0.5, + viewport.plot.y + 0.5, + Math.max(0, viewport.plot.width - 1), + Math.max(0, viewport.plot.height - 1), + ); + context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace"; + 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.textAlign = "right"; + context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8); + context.textAlign = "left"; + return viewport; +} + +function hitTestProjectedMicrophone(point, model, viewport, radiusPx = 12) { + let best = null; + for (const item of viewport?.projectedMicrophones ?? projectedMicrophones(model, viewport)) { + const distancePx = Math.hypot(item.point.x - point.x, item.point.y - point.y); + const isCloser = !best || distancePx < best.distancePx - 1e-9; + const isNearerTie = best && + Math.abs(distancePx - best.distancePx) <= 1e-9 && + item.point.depth > best.point.depth; + if (distancePx <= radiusPx && (isCloser || isNearerTie)) { + best = { ...item, id: item.microphone.id, distancePx }; + } + } + return best; +} + function normalizedTool(tool) { return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select"; } @@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) { let destroyed = false; let zoom = 1; let tool = "select"; + let viewMode = "2d"; + let orbit = { ...DEFAULT_ORBIT }; + let orbitDrag = null; let drag = null; let pendingAdd = null; @@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) { if (canvas.dataset) { canvas.dataset.tool = tool; canvas.dataset.zoom = String(zoom); + canvas.dataset.viewMode = viewMode; + canvas.dataset.orbiting = String(Boolean(orbitDrag)); + } + if (canvas.style) { + canvas.style.cursor = viewMode === "3d" + ? orbitDrag ? "grabbing" : "grab" + : tool === "add" ? "crosshair" : drag ? "grabbing" : "default"; } - if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default"; } function drawCurrent() { frameId = 0; if (destroyed) return; const size = prepareContext(canvas, context, view); - viewport = drawArray(context, size, model, zoom, drag); + viewport = viewMode === "3d" + ? drawArray3d(context, size, model, zoom, orbit) + : drawArray(context, size, model, zoom, drag); updateCanvasAffordance(); } @@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) { if (viewport) return viewport; if (!model?.arrayDefinition) return null; const rect = canvas.getBoundingClientRect(); - viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom }); + viewport = viewMode === "3d" + ? fitArrayViewport3d(rect.width, rect.height, model, zoom, orbit) + : fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom }); return viewport; } function render(nextModel) { if (destroyed) return; model = nextModel; + const nextViewMode = nextModel?.viewMode === "3d" ? "3d" : "2d"; + if (viewMode !== nextViewMode) { + viewMode = nextViewMode; + drag = null; + pendingAdd = null; + orbitDrag = null; + } tool = normalizedTool(nextModel?.tool ?? tool); + if (viewMode === "3d") tool = "select"; viewport = null; schedule(); } function setTool(nextTool) { - const resolved = normalizedTool(nextTool); + const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool); if (resolved === tool) return tool; if (resolved !== "add") pendingAdd = null; tool = resolved; @@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) { function fit() { zoom = 1; + if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT }; viewport = null; schedule(); return zoom; @@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) { TOUCH_GESTURE_THRESHOLD_PX; } + function finishOrbit(event) { + if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) { + return false; + } + const pointerId = event?.pointerId ?? orbitDrag.pointerId; + orbitDrag = null; + try { + if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId); + } catch { + // Capture can be released by the browser before cancellation arrives. + } + updateCanvasAffordance(); + schedule(); + return true; + } + function onTouchStart(event) { - if (destroyed || tool !== "select" || !model?.arrayDefinition) return; + if (destroyed || !model?.arrayDefinition) return; + if (viewMode === "3d") { + event.preventDefault(); + return; + } + if (tool !== "select") return; const touch = event.changedTouches?.[0] ?? event.touches?.[0]; const currentViewport = ensureViewport(); if (!touch || !currentViewport) return; @@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) { function onPointerDown(event) { if (destroyed || event.button !== 0 || !model?.arrayDefinition) return; - if (drag || pendingAdd) return; + if (drag || pendingAdd || orbitDrag) return; focus(); + if (viewMode === "3d") { + event.preventDefault(); + const currentViewport = ensureViewport(); + if (!currentViewport) return; + const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport); + callbacks.onSelect?.(hit?.id ?? null); + orbitDrag = { + pointerId: event.pointerId, + lastClient: clientPoint(event), + }; + try { + canvas.setPointerCapture?.(event.pointerId); + } catch { + // Synthetic pointers and interrupted input do not always own capture. + } + updateCanvasAffordance(); + schedule(); + return; + } if (tool === "add") { const position = constrainedEventPosition(event, 0); if (position) { @@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) { } function onPointerMove(event) { + if (orbitDrag?.pointerId === event.pointerId) { + const current = clientPoint(event); + 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); + viewport = null; + event.preventDefault(); + schedule(); + return; + } if (pendingAdd?.pointerId === event.pointerId) { if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null; return; @@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) { function onKeyDown(event) { if (event.altKey || event.ctrlKey || event.metaKey) return; + if (viewMode === "3d") { + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + if (key === "f" || key === "r") { + event.preventDefault(); + fit(); + callbacks.onFit?.(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + finishOrbit(); + return; + } + const orbitStep = event.shiftKey ? 0.18 : 0.06; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep; + viewport = null; + schedule(); + return; + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + orbit.pitch = clamp( + orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep), + -Math.PI * 0.46, + Math.PI * 0.46, + ); + viewport = null; + schedule(); + } + return; + } const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key; if (lowerKey === "a") { event.preventDefault(); @@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) { } function onPointerUp(event) { + if (finishOrbit(event)) return; if (finishPendingAdd(event, false)) return; finishDrag(event, false); } function onPointerCancel(event) { + if (finishOrbit(event)) return; if (finishPendingAdd(event, true)) return; finishDrag(event, true); } function onLostPointerCapture(event) { + if (orbitDrag?.pointerId === event.pointerId) { + finishOrbit(event); + return; + } if (drag?.pointerId === event.pointerId) finishDrag(event, false); } @@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) { canvas.removeEventListener("keydown", onKeyDown); model = null; viewport = null; + orbitDrag = null; drag = null; pendingAdd = null; }, diff --git a/apps/array-builder/index.html b/apps/array-builder/index.html index 7158485..1deff39 100644 --- a/apps/array-builder/index.html +++ b/apps/array-builder/index.html @@ -44,7 +44,7 @@

마이크 배치

-

Array 평면 편집

+

Array 2D 편집 · 3D 검사

0.45 × 0.45 m · 12 channels

@@ -54,6 +54,10 @@ +
+ + +
@@ -80,6 +84,7 @@ 마이크를 클릭하거나 드래그해 위치를 편집하세요. 추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.
+
+Z FRONT · 화면 바깥쪽
선택된 마이크 없음
@@ -96,7 +101,7 @@

배열 분석

수신 빔 패턴

- 1 kHz · XZ · +Z 조향 + 1 kHz · XZ/YZ/XY 동시
@@ -106,18 +111,30 @@ geometry-only

+
+ + +
+
-