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
+
+
+
+
+
-
-
-
-
- 국소 최대를 계산하고 있습니다.
-
-
+
+
+
+ XZ0° +Z · 90° +X
+
+ 계산 중…
+
+
+ YZ0° +Z · 90° +Y
+
+ 계산 중…
+
+
+ XY0° +X · 90° +Y
+
+ 계산 중…
+
+
+
+
+ 3D 구면 응답drag하여 회전 · +Z FRONT
+
+ 3D 표면을 준비하고 있습니다.
+
+
+
+
XZ계산 중…
+
YZ계산 중…
+
XY계산 중…
+
+
diff --git a/apps/array-builder/main.js b/apps/array-builder/main.js
index 1f6c548..631a66b 100644
--- a/apps/array-builder/main.js
+++ b/apps/array-builder/main.js
@@ -1,8 +1,12 @@
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
-import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js";
+import {
+ computeReceiveBeamPattern,
+ computeReceiveBeamPatternSphere,
+} from "/packages/acoustics-core/beam-pattern.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js";
+import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
import {
addMicrophone,
@@ -36,14 +40,22 @@ const requiredIds = [
"aperture-shape", "preset-type", "preset-count", "preset-columns",
"preset-rows", "preset-seed", "preset-minimum-spacing",
"generate-preset-button", "array-canvas", "select-tool-button",
- "add-tool-button", "fit-view-button", "zoom-in-button", "zoom-out-button",
+ "add-tool-button", "array-view-2d-button", "array-view-3d-button",
+ "canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
"remove-microphone-button", "validation-title", "validation-message",
- "beam-pattern-panel", "beam-pattern-frequency",
- "beam-pattern-speed-of-sound", "beam-pattern-cut",
- "beam-pattern-steering-angle", "beam-pattern-samples",
- "beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
+ "beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
+ "beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
+ "beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
+ "beam-pattern-samples", "beam-pattern-surface-resolution",
+ "beam-pattern-floor", "beam-pattern-view-cuts-button",
+ "beam-pattern-view-3d-button", "beam-pattern-cuts-view",
+ "beam-pattern-surface-view", "beam-pattern-surface",
+ "beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
+ "beam-pattern-caption-xz", "beam-pattern-caption-yz",
+ "beam-pattern-caption-xy", "beam-pattern-surface-caption",
+ "beam-pattern-status",
"beam-pattern-summary", "beam-pattern-wavelength",
"beam-pattern-resolution", "beam-pattern-steering-response",
"beam-pattern-hpbw", "beam-pattern-peaks",
@@ -62,7 +74,14 @@ const optionalIds = [
];
for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card");
-const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]);
+const BEAM_PATTERN_CUTS = Object.freeze(["xz", "yz", "xy"]);
+const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
+ cut,
+ createReceiveBeamPatternView(elements["beam-pattern-plot-" + cut]),
+]));
+const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
+ elements["beam-pattern-surface"],
+);
const splitPane = createSplitPane({
container: elements["array-builder-layout"],
@@ -92,6 +111,8 @@ const state = {
dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
+ arrayViewMode: "2d",
+ beamPatternViewMode: "cuts",
beamPatternRenderFrame: 0,
beamPatternDirty: true,
beamPatternPreviewPending: false,
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
}
function setTool(tool, options = {}) {
+ if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
state.tool = tool === "add" ? "add" : "select";
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
if (options.focus) canvasView.focus();
}
+function setArrayViewMode(mode, options = {}) {
+ const resolved = mode === "3d" ? "3d" : "2d";
+ state.arrayViewMode = resolved;
+ if (resolved === "3d") state.tool = "select";
+ elements["array-view-2d-button"].classList.toggle("is-active", resolved === "2d");
+ elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
+ elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
+ elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
+ elements["add-tool-button"].disabled = resolved === "3d";
+ elements["select-tool-button"].disabled = resolved === "3d";
+ const instruction = elements["canvas-instructions"];
+ if (instruction) {
+ const title = instruction.querySelector("strong");
+ const detail = instruction.querySelector("span");
+ if (title) {
+ title.textContent = resolved === "3d"
+ ? "드래그하여 배열을 회전하고 채널을 클릭해 선택하세요."
+ : "마이크를 클릭하거나 드래그해 위치를 편집하세요.";
+ }
+ if (detail) {
+ detail.textContent = resolved === "3d"
+ ? "3D는 검사 전용입니다. 방향키로 회전하고 R로 초기화하며, 좌표 수정은 2D 또는 x/y/z 입력에서 합니다."
+ : "추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.";
+ }
+ instruction.classList.remove("is-hidden");
+ }
+ elements["canvas-front-badge"].textContent = resolved === "3d"
+ ? "+Z FRONT · 초록 축"
+ : "+Z FRONT · 화면 바깥쪽";
+ elements["array-canvas"].setAttribute(
+ "aria-label",
+ resolved === "3d"
+ ? "마이크 어레이 3D 검사 canvas, 드래그하여 회전"
+ : "마이크 어레이 평면 편집 canvas",
+ );
+ elements["array-canvas"].setAttribute(
+ "aria-keyshortcuts",
+ resolved === "3d"
+ ? "ArrowLeft ArrowRight ArrowUp ArrowDown F R Escape"
+ : "V A F Escape Delete ArrowLeft ArrowRight ArrowUp ArrowDown",
+ );
+ setTool(state.tool);
+ canvasView.render(canvasState());
+ if (options.focus) canvasView.focus();
+ if (options.announce !== false) {
+ announceSelection(
+ resolved === "3d"
+ ? "3D 검사 · +Z가 FRONT입니다."
+ : "2D 편집 · +Z FRONT는 화면 바깥 방향입니다.",
+ );
+ }
+}
+
function canvasState() {
return {
arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
tool: state.tool,
+ viewMode: state.arrayViewMode,
snapStepM: state.snapEnabled ? state.snapStepM : 0,
};
}
@@ -496,13 +572,24 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
});
const beamPatternInputIds = [
- "beam-pattern-frequency",
"beam-pattern-speed-of-sound",
- "beam-pattern-cut",
"beam-pattern-steering-angle",
+ "beam-pattern-steering-azimuth",
+ "beam-pattern-steering-elevation",
"beam-pattern-samples",
+ "beam-pattern-surface-resolution",
"beam-pattern-floor",
];
+const BEAM_FREQUENCY_LOG_STEPS_PER_DECADE = 1000;
+const BEAM_PATTERN_LIVE_EVALUATION_BUDGET = 2_400_000;
+const BEAM_PATTERN_FULL_EVALUATION_BUDGET = 16_000_000;
+
+function beamPatternEvaluationBudget(preview, simultaneousViews = 1) {
+ const total = preview
+ ? BEAM_PATTERN_LIVE_EVALUATION_BUDGET
+ : BEAM_PATTERN_FULL_EVALUATION_BUDGET;
+ return Math.floor(total / simultaneousViews);
+}
function beamPatternNumber(id, label, options) {
const element = elements[id];
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
}
}
-function readBeamPatternOptions(preview) {
- for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
- const frequencyHz = beamPatternNumber(
- "beam-pattern-frequency",
- "아날로그 주파수",
- { exclusiveMinimum: 0, maximum: 1_000_000 },
+function beamPatternFrequencyHz() {
+ const slider = elements["beam-pattern-frequency"];
+ const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
+ minimum: 0,
+ maximum: 6000,
+ });
+ return Number(
+ Math.pow(10, logarithmicPosition / BEAM_FREQUENCY_LOG_STEPS_PER_DECADE)
+ .toPrecision(12),
);
+}
+
+function syncBeamPatternFrequency() {
+ const frequencyHz = beamPatternFrequencyHz();
+ const formatted = formatBeamFrequency(frequencyHz);
+ elements["beam-pattern-frequency-value"].textContent = formatted;
+ elements["beam-pattern-frequency"].setAttribute("aria-valuenow", String(frequencyHz));
+ elements["beam-pattern-frequency"].setAttribute("aria-valuetext", formatted);
+ return frequencyHz;
+}
+
+function readBeamPatternCommonOptions() {
+ for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
+ const frequencyHz = syncBeamPatternFrequency();
const speedOfSound = beamPatternNumber(
"beam-pattern-speed-of-sound",
"음속",
{ minimum: 250, maximum: 400 },
);
- const steeringAngleDeg = beamPatternNumber(
- "beam-pattern-steering-angle",
- "조향각",
- { minimum: -360, maximum: 360 },
- );
- const cut = elements["beam-pattern-cut"].value;
- if (!["xy", "xz", "yz"].includes(cut)) {
- elements["beam-pattern-cut"].setAttribute("aria-invalid", "true");
- throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다.");
- }
- const sampleValue = elements["beam-pattern-samples"].value;
- const configuredSampleCount = sampleValue === "auto"
- ? "auto"
- : Number(sampleValue);
- if (
- configuredSampleCount !== "auto" &&
- (!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
- ) {
- elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
- throw new Error("각도 샘플 설정이 올바르지 않습니다.");
- }
const dbFloor = Number(elements["beam-pattern-floor"].value);
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
array: state.arrayDefinition,
frequencyHz,
speedOfSound,
- cut,
- steeringAngleDeg,
- sampleCount: preview
- ? Math.min(
- configuredSampleCount === "auto" ? 720 : configuredSampleCount,
- 720,
- )
- : configuredSampleCount,
dbFloor,
};
}
+function readBeamPatternCutOptions(preview) {
+ const common = readBeamPatternCommonOptions();
+ const steeringAngleDeg = beamPatternNumber(
+ "beam-pattern-steering-angle",
+ "단면별 조향각",
+ { minimum: -360, maximum: 360 },
+ );
+ const sampleValue = elements["beam-pattern-samples"].value;
+ const configuredSampleCount = sampleValue === "auto" ? "auto" : Number(sampleValue);
+ if (
+ configuredSampleCount !== "auto" &&
+ (!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
+ ) {
+ elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
+ throw new Error("각도 샘플 설정이 올바르지 않습니다.");
+ }
+ return {
+ ...common,
+ steeringAngleDeg,
+ maximumEvaluations: beamPatternEvaluationBudget(
+ preview,
+ BEAM_PATTERN_CUTS.length,
+ ),
+ sampleCount: preview
+ ? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
+ : configuredSampleCount,
+ };
+}
+
+function readBeamPatternSurfaceOptions(preview) {
+ const common = readBeamPatternCommonOptions();
+ const steeringAzimuthDeg = beamPatternNumber(
+ "beam-pattern-steering-azimuth",
+ "3D 조향 방위각",
+ { minimum: -360, maximum: 360 },
+ );
+ const steeringElevationDeg = beamPatternNumber(
+ "beam-pattern-steering-elevation",
+ "3D 조향 고도각",
+ { minimum: -90, maximum: 90 },
+ );
+ const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
+ if (
+ !Number.isFinite(configuredStepDeg) ||
+ configuredStepDeg < 1 ||
+ configuredStepDeg > 15 ||
+ 360 % configuredStepDeg !== 0 ||
+ 180 % configuredStepDeg !== 0
+ ) {
+ elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
+ throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
+ }
+ const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
+ return {
+ ...common,
+ steeringAzimuthDeg,
+ steeringElevationDeg,
+ maximumEvaluations: beamPatternEvaluationBudget(preview),
+ azimuthSampleCount: Math.round(360 / stepDeg),
+ elevationSampleCount: Math.round(180 / stepDeg) + 1,
+ };
+}
+
function formatBeamFrequency(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";
@@ -624,80 +762,198 @@ function setBeamPatternStatus(message, severity = "success") {
elements["beam-pattern-status"].dataset.severity = severity;
}
+function renderBeamPatternCuts(preview, startedAt) {
+ const options = readBeamPatternCutOptions(preview);
+ const patterns = BEAM_PATTERN_CUTS.map((cut) =>
+ computeReceiveBeamPattern({ ...options, cut }));
+ for (const pattern of patterns) {
+ const cut = pattern.cut;
+ const plot = elements["beam-pattern-plot-" + cut];
+ beamPatternViews[cut].render(pattern);
+ plot.dataset.sampleCount = String(pattern.sampleCount);
+ plot.dataset.cut = cut;
+ plot.dataset.frequencyHz = String(pattern.frequencyHz);
+ elements["beam-pattern-caption-" + cut].textContent =
+ pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
+ (preview
+ ? "live preview · 놓으면 HPBW 재계산"
+ : "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
+ const diagnostic = elements["beam-pattern-peaks"].querySelector(
+ '[data-cut="' + cut + '"] span',
+ );
+ if (diagnostic) {
+ diagnostic.textContent = preview
+ ? "live raw samples · 놓으면 peak 재계산"
+ : describeBeamPatternPeaks(pattern);
+ }
+ }
+
+ const reference = patterns[0];
+ elements["beam-pattern-summary"].textContent =
+ formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
+ reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
+ elements["beam-pattern-wavelength"].textContent =
+ formatBeamWavelength(reference.wavelengthM);
+ elements["beam-pattern-resolution"].textContent = patterns
+ .map((pattern) =>
+ pattern.cut.toUpperCase() + " " +
+ pattern.sampleCount.toLocaleString("ko-KR") + " / " +
+ pattern.angularResolutionDeg.toFixed(3) + "°")
+ .join(" · ");
+ elements["beam-pattern-steering-response"].textContent = patterns
+ .map((pattern) =>
+ pattern.cut.toUpperCase() + " " +
+ formatBeamDecibels(pattern.metrics.steeringDb))
+ .join(" · ");
+ elements["beam-pattern-hpbw"].textContent = preview
+ ? "slider/drag 종료 후 계산"
+ : patterns
+ .map((pattern) =>
+ pattern.cut.toUpperCase() + " " +
+ formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
+ .join(" · ");
+
+ if (!preview) {
+ const elapsedMs = performance.now() - startedAt;
+ const unresolved = patterns.filter(
+ (pattern) => !pattern.angularSampling.metricResolutionSatisfied,
+ );
+ const missingCrossing = patterns.filter(
+ (pattern) =>
+ pattern.metrics.halfPowerBeamwidth.status ===
+ "no-sampled-half-power-crossing",
+ );
+ const evaluationCount = patterns.reduce(
+ (sum, pattern) => sum + pattern.evaluationCount,
+ 0,
+ );
+ document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
+ if (unresolved.length) {
+ setBeamPatternStatus(
+ unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
+ " raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
+ elapsedMs.toFixed(1) + " ms",
+ "warning",
+ );
+ } else if (missingCrossing.length) {
+ setBeamPatternStatus(
+ missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
+ "에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
+ elapsedMs.toFixed(1) + " ms",
+ "warning",
+ );
+ } else {
+ setBeamPatternStatus(
+ "세 평면 π/8 인접 위상 기준 충족 · " +
+ evaluationCount.toLocaleString("ko-KR") +
+ " microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
+ "success",
+ );
+ }
+ }
+}
+
+function renderBeamPatternSurface(preview, startedAt) {
+ const options = readBeamPatternSurfaceOptions(preview);
+ const surface = computeReceiveBeamPatternSphere(options);
+ beamPatternSurfaceView.render(surface);
+ elements["beam-pattern-summary"].textContent =
+ formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
+ surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
+ surface.steeringElevationDeg.toFixed(1) + "°";
+ elements["beam-pattern-wavelength"].textContent =
+ formatBeamWavelength(surface.wavelengthM);
+ elements["beam-pattern-resolution"].textContent =
+ surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
+ surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
+ surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
+ elements["beam-pattern-steering-response"].textContent =
+ "0.000 dB · analytic u=u₀";
+ elements["beam-pattern-hpbw"].textContent =
+ "3D 표면 · 평면 HPBW 미적용";
+ elements["beam-pattern-surface-caption"].textContent =
+ surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
+ " raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
+ (preview ? "live preview" : "drag/방향키로 회전");
+ if (!preview) {
+ const elapsedMs = performance.now() - startedAt;
+ document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
+ if (!surface.angularSampling.resolutionCriterionSatisfied) {
+ setBeamPatternStatus(
+ "Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
+ surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
+ " rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
+ elapsedMs.toFixed(1) + " ms",
+ "warning",
+ );
+ } else {
+ setBeamPatternStatus(
+ "3D 구면 π/8 cell 대각 위상 기준 충족 · " +
+ surface.evaluationCount.toLocaleString("ko-KR") +
+ " microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
+ "success",
+ );
+ }
+ }
+}
+
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
- const options = readBeamPatternOptions(preview);
- const pattern = computeReceiveBeamPattern(options);
- beamPatternView.render(pattern);
+ if (state.beamPatternViewMode === "surface") {
+ renderBeamPatternSurface(preview, startedAt);
+ } else {
+ renderBeamPatternCuts(preview, startedAt);
+ }
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "true";
document.documentElement.dataset.beamPatternPreview = String(preview);
- elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
- elements["beam-pattern-plot"].dataset.cut = pattern.cut;
- elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
- elements["beam-pattern-summary"].textContent =
- formatBeamFrequency(pattern.frequencyHz) + " · " +
- pattern.cut.toUpperCase() + " · " +
- pattern.steeringAngleDeg.toFixed(1) + "° 조향";
- elements["beam-pattern-wavelength"].textContent =
- formatBeamWavelength(pattern.wavelengthM);
- elements["beam-pattern-resolution"].textContent =
- pattern.sampleCount.toLocaleString("ko-KR") + " · " +
- pattern.angularResolutionDeg.toFixed(4) + "°";
- elements["beam-pattern-steering-response"].textContent =
- formatBeamDecibels(pattern.metrics.steeringDb);
- elements["beam-pattern-hpbw"].textContent = preview
- ? "드래그 종료 후 계산"
- : formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
-
- if (!preview) {
- elements["beam-pattern-peaks"].textContent =
- describeBeamPatternPeaks(pattern);
- const elapsedMs = performance.now() - startedAt;
- document.documentElement.dataset.beamPatternComputeMs =
- elapsedMs.toFixed(3);
- if (!pattern.angularSampling.metricResolutionSatisfied) {
- setBeamPatternStatus(
- "Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
- pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
- " rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
- (pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
- " · " + elapsedMs.toFixed(1) + " ms",
- "warning",
- );
- } else if (
- pattern.metrics.halfPowerBeamwidth.status ===
- "no-sampled-half-power-crossing"
- ) {
- setBeamPatternStatus(
- "π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
- "전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
- "warning",
- );
- } else {
- setBeamPatternStatus(
- "π/8 인접 위상 간격 기준 충족 · " +
- pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
- pattern.evaluationCount.toLocaleString("ko-KR") +
- " phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
- "success",
- );
- }
- }
+ document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
} catch (error) {
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error";
- document.documentElement.dataset.beamPatternPreview = "false";
+ document.documentElement.dataset.beamPatternPreview = String(preview);
const message = error instanceof Error ? error.message : String(error);
- beamPatternView.renderError(message);
+ for (const view of Object.values(beamPatternViews)) view.renderError(message);
+ beamPatternSurfaceView.renderError(message);
setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—";
- elements["beam-pattern-peaks"].textContent =
- "빔 패턴 설정을 수정하면 다시 계산합니다.";
+ for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
+ diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
+ }
}
}
+function setBeamPatternViewMode(mode, options = {}) {
+ const resolved = mode === "surface" ? "surface" : "cuts";
+ state.beamPatternViewMode = resolved;
+ elements["beam-pattern-view-cuts-button"].classList.toggle(
+ "is-active",
+ resolved === "cuts",
+ );
+ elements["beam-pattern-view-3d-button"].classList.toggle(
+ "is-active",
+ resolved === "surface",
+ );
+ elements["beam-pattern-view-cuts-button"].setAttribute(
+ "aria-pressed",
+ String(resolved === "cuts"),
+ );
+ elements["beam-pattern-view-3d-button"].setAttribute(
+ "aria-pressed",
+ String(resolved === "surface"),
+ );
+ for (const field of document.querySelectorAll("[data-beam-mode]")) {
+ field.hidden = field.dataset.beamMode !== resolved;
+ }
+ elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
+ elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
+ elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
+ document.documentElement.dataset.beamPatternView = resolved;
+ state.beamPatternDirty = true;
+ if (options.render !== false) scheduleBeamPatternRender();
+}
+
function scheduleBeamPatternRender(options = {}) {
const preview = options.preview === true;
state.beamPatternDirty = true;
@@ -1093,6 +1349,10 @@ 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["array-view-2d-button"].addEventListener("click", () =>
+ setArrayViewMode("2d", { focus: true }));
+elements["array-view-3d-button"].addEventListener("click", () =>
+ setArrayViewMode("3d", { focus: 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));
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
});
+elements["beam-pattern-frequency"].addEventListener("input", () => {
+ syncBeamPatternFrequency();
+ scheduleBeamPatternRender({ preview: true });
+});
+elements["beam-pattern-frequency"].addEventListener("change", () =>
+ scheduleBeamPatternRender());
for (const id of beamPatternInputIds) {
- const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
- elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
+ if (elements[id] instanceof HTMLInputElement) {
+ elements[id].addEventListener("input", () =>
+ scheduleBeamPatternRender({ preview: true }));
+ elements[id].addEventListener("change", () => scheduleBeamPatternRender());
+ } else {
+ elements[id].addEventListener("change", () => scheduleBeamPatternRender());
+ }
}
+elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
+ setBeamPatternViewMode("cuts"));
+elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
+ setBeamPatternViewMode("surface"));
elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender();
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
if (state.beamPatternRenderFrame) {
cancelAnimationFrame(state.beamPatternRenderFrame);
}
- beamPatternView.destroy();
+ for (const view of Object.values(beamPatternViews)) view.destroy();
+ beamPatternSurfaceView.destroy();
splitPane.destroy();
canvasView.destroy();
});
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
+setArrayViewMode("2d", { announce: false });
+setBeamPatternViewMode("cuts", { render: false });
+syncBeamPatternFrequency();
renderAll("기본 4×3 배열");
canvasView.fit();
document.documentElement.dataset.appReady = "true";
diff --git a/apps/array-builder/styles.css b/apps/array-builder/styles.css
index e3acd06..a2fe2bb 100644
--- a/apps/array-builder/styles.css
+++ b/apps/array-builder/styles.css
@@ -633,7 +633,9 @@ details > summary::-webkit-details-marker {
}
.beam-pattern-controls,
-.beam-pattern-figure {
+.beam-pattern-visuals,
+.beam-pattern-figure,
+.beam-pattern-surface-figure {
min-width: 0;
}
@@ -658,12 +660,59 @@ details > summary::-webkit-details-marker {
font-size: 0.55rem;
}
+.beam-pattern-view-switch {
+ display: inline-flex;
+ gap: 5px;
+ margin-bottom: 11px;
+ padding: 3px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #07110f;
+}
+
+.beam-pattern-view-switch .tool-button {
+ min-width: 82px;
+}
+
.beam-pattern-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
+.beam-frequency-field {
+ grid-column: 1 / -1;
+}
+
+.beam-frequency-field > small,
+.beam-pattern-fields .field > small {
+ margin-top: 5px;
+ color: var(--muted);
+ font-size: 0.56rem;
+ line-height: 1.4;
+}
+
+.beam-frequency-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(92px, auto);
+ align-items: center;
+ gap: 10px;
+}
+
+#beam-pattern-frequency {
+ min-width: 0;
+ padding-inline: 0;
+ accent-color: var(--mint);
+ cursor: ew-resize;
+}
+
+#beam-pattern-frequency-value {
+ color: var(--mint);
+ font: 760 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ text-align: right;
+ white-space: nowrap;
+}
+
.beam-pattern-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -728,36 +777,139 @@ details > summary::-webkit-details-marker {
overflow-wrap: anywhere;
}
-.beam-pattern-figure {
+.beam-pattern-visuals {
+ display: grid;
+ min-width: 0;
+ align-content: start;
+ gap: 11px;
+}
+
+.beam-pattern-cut-grid {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.beam-pattern-figure,
+.beam-pattern-surface-figure {
display: grid;
align-content: start;
- justify-items: center;
- gap: 9px;
+ gap: 7px;
margin: 0;
}
-#beam-pattern-plot {
+.beam-pattern-figure-heading {
+ display: flex;
+ min-width: 0;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 7px;
+}
+
+.beam-pattern-figure-heading strong {
+ color: #edf9f4;
+ font-size: 0.7rem;
+}
+
+.beam-pattern-figure-heading span {
+ min-width: 0;
+ color: var(--muted);
+ font: 620 0.53rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+#beam-pattern-plot-xz,
+#beam-pattern-plot-yz,
+#beam-pattern-plot-xy {
display: block;
- width: min(100%, 580px);
+ width: 100%;
height: auto;
aspect-ratio: 1;
overflow: visible;
- border: 1px solid var(--line);
- border-radius: 14px;
+ border: 1px solid rgb(145 170 162 / 22%);
+ border-radius: 11px;
color: var(--mint);
background: #07110f;
}
.beam-pattern-figure figcaption {
- width: min(100%, 580px);
- min-height: 2.8em;
+ min-height: 3.8em;
margin: 0;
color: var(--muted);
- font-size: 0.61rem;
+ font-size: 0.56rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
+.beam-pattern-surface-figure {
+ justify-items: stretch;
+}
+
+#beam-pattern-surface {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 16 / 10;
+ border: 1px solid rgb(145 170 162 / 24%);
+ border-radius: 12px;
+ background: #07110f;
+ cursor: grab;
+ touch-action: none;
+}
+
+#beam-pattern-surface[data-orbiting="true"] {
+ cursor: grabbing;
+}
+
+#beam-pattern-surface:focus-visible {
+ outline: 2px solid var(--mint);
+ outline-offset: -3px;
+}
+
+.beam-pattern-surface-figure figcaption {
+ color: var(--muted);
+ font-size: 0.58rem;
+ line-height: 1.45;
+}
+
+.beam-pattern-diagnostics {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 7px;
+}
+
+.beam-pattern-diagnostics p {
+ min-width: 0;
+ margin: 0;
+ padding: 8px 9px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ color: var(--muted);
+ background: rgb(255 255 255 / 1.5%);
+ font-size: 0.55rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.beam-pattern-diagnostics strong,
+.beam-pattern-diagnostics span {
+ display: block;
+}
+
+.beam-pattern-diagnostics strong {
+ margin-bottom: 3px;
+ color: #cfe2db;
+}
+
+[data-beam-mode][hidden],
+#beam-pattern-cuts-view[hidden],
+#beam-pattern-surface-view[hidden],
+#beam-pattern-peaks[hidden] {
+ display: none !important;
+}
+
.beam-pattern-ring,
.beam-pattern-axis {
stroke: rgb(145 170 162 / 28%);
@@ -976,6 +1128,15 @@ body[data-array-tool="add"] #array-canvas,
cursor: crosshair;
}
+#array-canvas[data-view-mode="3d"] {
+ cursor: grab;
+ touch-action: none;
+}
+
+#array-canvas[data-view-mode="3d"][data-orbiting="true"] {
+ cursor: grabbing;
+}
+
.canvas-instructions {
position: absolute;
top: 24px;
@@ -1002,6 +1163,24 @@ body[data-array-tool="add"] #array-canvas,
display: none;
}
+.canvas-front-badge {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ max-width: calc(100% - 190px);
+ padding: 7px 10px;
+ border: 1px solid rgb(88 199 232 / 30%);
+ border-radius: 999px;
+ color: var(--cyan);
+ background: rgb(8 17 15 / 84%);
+ backdrop-filter: blur(10px);
+ font: 720 0.62rem ui-monospace, SFMono-Regular, Consolas, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
.selection-status {
position: absolute;
right: 12px;
@@ -1093,6 +1272,17 @@ body[data-array-tool="add"] #array-canvas,
}
}
+@container beam-pattern (max-width: 620px) {
+ .beam-pattern-cut-grid,
+ .beam-pattern-diagnostics {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .beam-pattern-figure figcaption {
+ min-height: 0;
+ }
+}
+
@container beam-pattern (max-width: 460px) {
.beam-pattern-heading {
align-items: flex-start;
@@ -1106,6 +1296,19 @@ body[data-array-tool="add"] #array-canvas,
.beam-pattern-metrics {
grid-template-columns: minmax(0, 1fr);
}
+
+ .beam-frequency-field {
+ grid-column: auto;
+ }
+
+ .beam-frequency-control {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 5px;
+ }
+
+ #beam-pattern-frequency-value {
+ text-align: left;
+ }
}
@media (max-width: 1280px) {
diff --git a/tests/integration/array-builder-ui.test.js b/tests/integration/array-builder-ui.test.js
index 9301f50..39ea2cc 100644
--- a/tests/integration/array-builder-ui.test.js
+++ b/tests/integration/array-builder-ui.test.js
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
+function logarithmicFrequencyPosition(frequencyHz) {
+ return Math.round(Math.log10(frequencyHz) * 1000);
+}
+
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
for (const file of files) transfer.items.add(file);
@@ -102,20 +106,46 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.documentElement.dataset.beamPatternReady === "true",
"기본 수신 빔 패턴 렌더",
);
- const beamPlot = documentObject.querySelector("#beam-pattern-plot");
- const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
- ?.getAttribute("d");
- assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
- assertEqual(
- beamPlot.querySelectorAll(".beam-pattern-ring").length,
- 5,
- "polar plot has four inner dB rings and the 0 dB ring",
- );
- assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
- assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
+ const beamCuts = ["xz", "yz", "xy"];
+ const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
+ cut,
+ documentObject.querySelector("#beam-pattern-plot-" + cut),
+ ]));
+ const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
+ cut,
+ beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
+ ]));
+ for (const cut of beamCuts) {
+ const beamPlot = beamPlots[cut];
+ assert(
+ initialBeamPaths[cut]?.startsWith("M"),
+ cut.toUpperCase() + " polar response path is rendered",
+ );
+ assertEqual(
+ beamPlot.querySelectorAll(".beam-pattern-ring").length,
+ 5,
+ cut.toUpperCase() + " polar plot has four inner dB rings and the 0 dB ring",
+ );
+ assertEqual(beamPlot.dataset.cut, cut, cut.toUpperCase() + " cut provenance");
+ assertEqual(
+ beamPlot.dataset.frequencyHz,
+ "1000",
+ cut.toUpperCase() + " default analog frequency",
+ );
+ assert(
+ Number(beamPlot.dataset.sampleCount) >= 720,
+ cut.toUpperCase() + " auto angular sampling uses at least 720 directions",
+ );
+ }
assert(
- Number(beamPlot.dataset.sampleCount) >= 720,
- "auto angular sampling uses at least 720 directions",
+ !documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
+ documentObject.querySelector("#beam-pattern-surface-view").hidden,
+ "three polar cuts are the simultaneous default view",
+ );
+ assertEqual(
+ documentObject.querySelectorAll("#beam-pattern-cuts-view .beam-pattern-figure").length,
+ 3,
+ "XZ, YZ, and XY figures are present together",
);
assert(
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
@@ -125,16 +155,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const initialSaveState = documentObject.querySelector("#save-state").textContent;
+ const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
dispatchValue(
windowObject,
- documentObject.querySelector("#beam-pattern-frequency"),
- 49_125,
+ frequencySlider,
+ logarithmicFrequencyPosition(49_125),
"input",
);
await waitFor(
- () => beamPlot.dataset.frequencyHz === "49125" &&
- Number(beamPlot.dataset.sampleCount) > 720,
- "above-Nyquist-style analog pattern with auto angular sampling",
+ () => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
+ beamCuts.every((cut) =>
+ Number(beamPlots[cut].dataset.frequencyHz) > 48_000 &&
+ beamPlots[cut].dataset.sampleCount === "720"),
+ "logarithmic slider input renders a bounded live preview in every cut",
+ );
+ const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow"));
+ assert(
+ previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000,
+ "log slider position maps to the intended above-Nyquist-style analog frequency",
+ );
+ assert(
+ documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"),
+ "log slider exposes its physical frequency value",
+ );
+ frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
+ beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720),
+ "logarithmic slider change renders full auto resolution in every cut",
);
assert(
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
360,
);
await waitFor(
- () => beamPlot.dataset.sampleCount === "360" &&
+ () => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족",
),
- "coarse angular grid exposes unresolved metrics",
+ "coarse angular grid exposes unresolved metrics for the simultaneous cuts",
);
dispatchValue(
windowObject,
- documentObject.querySelector("#beam-pattern-frequency"),
+ documentObject.querySelector("#beam-pattern-speed-of-sound"),
"",
"input",
);
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
dispatchValue(
windowObject,
- documentObject.querySelector("#beam-pattern-samples"),
- "auto",
+ documentObject.querySelector("#beam-pattern-speed-of-sound"),
+ 343,
+ "input",
);
dispatchValue(
windowObject,
- documentObject.querySelector("#beam-pattern-frequency"),
- 1_000,
+ frequencySlider,
+ logarithmicFrequencyPosition(1_000),
"input",
);
+ frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#beam-pattern-samples"),
+ "auto",
+ );
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
- beamPlot.dataset.frequencyHz === "1000",
+ documentObject.documentElement.dataset.beamPatternPreview === "false" &&
+ beamCuts.every((cut) =>
+ beamPlots[cut].dataset.frequencyHz === "1000" &&
+ Number(beamPlots[cut].dataset.sampleCount) >= 720),
"beam preview recovery",
);
assertEqual(
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
initialSaveState,
"beam preview settings are outside Array dirty state",
);
+
+ documentObject.querySelector("#beam-pattern-view-3d-button").click();
+ const beamSurface = documentObject.querySelector("#beam-pattern-surface");
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternView === "surface" &&
+ documentObject.documentElement.dataset.beamPatternReady === "true" &&
+ documentObject.documentElement.dataset.beamPatternPreview === "false" &&
+ !documentObject.querySelector("#beam-pattern-surface-view").hidden &&
+ documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
+ Number(beamSurface.dataset.sampleCount) > 0,
+ "3D beam surface render",
+ );
+ assert(
+ /^\d+x\d+$/.test(beamSurface.dataset.gridShape),
+ "3D beam canvas records its raw sphere grid",
+ );
+ assertEqual(
+ documentObject.querySelector("#beam-pattern-view-3d-button").getAttribute("aria-pressed"),
+ "true",
+ "3D beam view toggle is pressed",
+ );
+ assert(
+ documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
+ "raw grid samples",
+ ),
+ "3D beam caption identifies raw sampled directions",
+ );
+ assert(
+ documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
+ "dB-floor radial mapping",
+ ),
+ "3D beam caption discloses the display-only radial mapping",
+ );
+ await nextPaint(windowObject);
+ const surfaceBeforeOrbit = beamSurface.toDataURL();
+ const beamSurfaceRect = beamSurface.getBoundingClientRect();
+ const beamOrbitPointerId = 71;
+ beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: beamOrbitPointerId,
+ pointerType: "mouse",
+ button: 0,
+ buttons: 1,
+ clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.5,
+ clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.5,
+ }));
+ assertEqual(beamSurface.dataset.orbiting, "true", "3D beam drag begins orbiting");
+ beamSurface.dispatchEvent(new windowObject.PointerEvent("pointermove", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: beamOrbitPointerId,
+ pointerType: "mouse",
+ button: -1,
+ buttons: 1,
+ clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.7,
+ clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.36,
+ }));
+ beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerup", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: beamOrbitPointerId,
+ pointerType: "mouse",
+ button: 0,
+ buttons: 0,
+ }));
+ await nextPaint(windowObject);
+ assert(!beamSurface.dataset.orbiting, "3D beam drag finishes orbiting");
+ assert(
+ beamSurface.toDataURL() !== surfaceBeforeOrbit,
+ "3D beam surface bitmap changes after orbit rotation",
+ );
+ documentObject.querySelector("#beam-pattern-view-cuts-button").click();
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
+ documentObject.documentElement.dataset.beamPatternReady === "true" &&
+ !documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
+ documentObject.querySelector("#beam-pattern-surface-view").hidden,
+ "return to simultaneous polar cuts",
+ );
+
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
@@ -203,6 +342,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const canvas = documentObject.querySelector("#array-canvas");
+ assertEqual(canvas.dataset.viewMode, "2d", "array canvas starts in editable 2D mode");
+ assert(
+ documentObject.querySelector("#canvas-front-badge").textContent.includes(
+ "+Z FRONT · 화면 바깥쪽",
+ ),
+ "2D canvas states the +Z front direction",
+ );
+ const coordinatesBeforeArray3d = selectedCoordinates(documentObject);
+ const microphoneCountBeforeArray3d = microphoneCount(documentObject);
+ documentObject.querySelector("#array-view-3d-button").click();
+ await waitFor(
+ () => canvas.dataset.viewMode === "3d" &&
+ canvas.getAttribute("aria-label").includes("3D 검사"),
+ "array 3D inspection mode",
+ );
+ assert(
+ documentObject.querySelector("#add-tool-button").disabled &&
+ documentObject.querySelector("#select-tool-button").disabled,
+ "array canvas edit tools are disabled in 3D inspection",
+ );
+ assert(
+ documentObject.querySelector("#canvas-front-badge").textContent.includes(
+ "+Z FRONT · 초록 축",
+ ),
+ "3D canvas identifies the +Z front axis",
+ );
+ assert(
+ documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
+ "3D array guidance explains that coordinate editing is disabled",
+ );
+ canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "a",
+ }));
+ await nextPaint(windowObject);
+ assertEqual(canvas.dataset.tool, "select", "A shortcut cannot enable add in 3D inspection");
+ assertEqual(
+ microphoneCount(documentObject),
+ microphoneCountBeforeArray3d,
+ "3D inspection cannot add a microphone",
+ );
+ assertEqual(
+ selectedCoordinates(documentObject).join(","),
+ coordinatesBeforeArray3d.join(","),
+ "3D inspection leaves microphone coordinates unchanged",
+ );
+ const array3dBeforeOrbit = canvas.toDataURL();
+ const array3dRect = canvas.getBoundingClientRect();
+ const arrayOrbitPointerId = 72;
+ canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: arrayOrbitPointerId,
+ pointerType: "mouse",
+ button: 0,
+ buttons: 1,
+ clientX: array3dRect.left + array3dRect.width * 0.5,
+ clientY: array3dRect.top + array3dRect.height * 0.5,
+ }));
+ assertEqual(canvas.dataset.orbiting, "true", "array 3D drag begins orbiting");
+ canvas.dispatchEvent(new windowObject.PointerEvent("pointermove", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: arrayOrbitPointerId,
+ pointerType: "mouse",
+ button: -1,
+ buttons: 1,
+ clientX: array3dRect.left + array3dRect.width * 0.68,
+ clientY: array3dRect.top + array3dRect.height * 0.4,
+ }));
+ canvas.dispatchEvent(new windowObject.PointerEvent("pointerup", {
+ bubbles: true,
+ cancelable: true,
+ pointerId: arrayOrbitPointerId,
+ pointerType: "mouse",
+ button: 0,
+ buttons: 0,
+ }));
+ await nextPaint(windowObject);
+ assertEqual(canvas.dataset.orbiting, "false", "array 3D drag finishes orbiting");
+ assert(
+ canvas.toDataURL() !== array3dBeforeOrbit,
+ "array 3D canvas bitmap changes after orbit rotation",
+ );
+ documentObject.querySelector("#array-view-2d-button").click();
+ await waitFor(
+ () => canvas.dataset.viewMode === "2d" &&
+ canvas.getAttribute("aria-label").includes("평면 편집"),
+ "return to array 2D editing",
+ );
+ assert(
+ !documentObject.querySelector("#add-tool-button").disabled &&
+ !documentObject.querySelector("#select-tool-button").disabled,
+ "array canvas edit tools are restored in 2D",
+ );
+ assert(
+ documentObject.querySelector("#canvas-front-badge").textContent.includes(
+ "+Z FRONT · 화면 바깥쪽",
+ ),
+ "returning to 2D restores its +Z front cue",
+ );
+ documentObject.querySelector(
+ '#microphone-list [data-microphone-id="mic-001"]',
+ ).click();
+ assertEqual(
+ selectedCoordinates(documentObject).join(","),
+ coordinatesBeforeArray3d.join(","),
+ "2D edit resumes from the original microphone coordinates",
+ );
const defaultArray = createArrayDefinition();
const initialCanvasRect = canvas.getBoundingClientRect();
const initialViewport = fitArrayViewport(
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
dispatchDragPointer("pointerup", 36, -24, 0);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
- beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
- initialBeamPath,
- "beam pattern follows edited microphone geometry",
+ beamCuts.every((cut) =>
+ beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
+ initialBeamPaths[cut]),
+ "all beam-pattern cuts follow edited microphone geometry",
);
const draggedPosition = selectedCoordinates(documentObject);
assert(
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px",
);
- const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
- .getBoundingClientRect();
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
+ const compactBeamPlotRects = beamCuts.map((cut) =>
+ beamPlots[cut].getBoundingClientRect());
assert(
- compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
- compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
- "compact polar plot stays inside its analysis card",
+ compactBeamPlotRects.every((rect) =>
+ rect.left >= compactBeamCardRect.left - 1 &&
+ rect.right <= compactBeamCardRect.right + 1),
+ "all compact polar plots stay inside their analysis card",
);
assert(
- Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
- "compact polar plot remains square",
+ compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
+ "all compact polar plots remain square",
+ );
+ assert(
+ compactBeamPlotRects.every(
+ (rect, index) => index === 0 || rect.top > compactBeamPlotRects[index - 1].bottom,
+ ),
+ "compact XZ, YZ, and XY plots stack without overlap",
);
const compactBeamFields = [
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
- ].map((field) => field.getBoundingClientRect());
+ ].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
assert(
compactBeamFields.every(
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
),
"compact beam controls stack without clipping",
);
+ documentObject.querySelector("#beam-pattern-view-3d-button").click();
+ await waitFor(
+ () => documentObject.documentElement.dataset.beamPatternView === "surface" &&
+ documentObject.documentElement.dataset.beamPatternReady === "true" &&
+ !documentObject.querySelector("#beam-pattern-surface-view").hidden &&
+ Number(beamSurface.dataset.sampleCount) > 0,
+ "compact 3D beam surface",
+ );
+ await nextPaint(windowObject);
+ const compactBeamSurfaceRect = beamSurface.getBoundingClientRect();
+ const compactBeamSurfaceCardRect = documentObject.querySelector("#beam-pattern-panel")
+ .getBoundingClientRect();
+ assert(
+ compactBeamSurfaceRect.left >= compactBeamSurfaceCardRect.left - 1 &&
+ compactBeamSurfaceRect.right <= compactBeamSurfaceCardRect.right + 1,
+ "compact 3D beam surface stays inside its analysis card",
+ );
+ assertNear(
+ compactBeamSurfaceRect.width / compactBeamSurfaceRect.height,
+ 16 / 10,
+ 0.03,
+ "compact 3D beam surface preserves its aspect ratio",
+ );
+ assert(
+ documentObject.documentElement.scrollWidth <=
+ documentObject.documentElement.clientWidth + 1,
+ "3D beam mode has no horizontal overflow at 390px",
+ );
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px",
diff --git a/tests/unit/array-builder-canvas.test.js b/tests/unit/array-builder-canvas.test.js
index 16b11c3..40125d2 100644
--- a/tests/unit/array-builder-canvas.test.js
+++ b/tests/unit/array-builder-canvas.test.js
@@ -7,6 +7,7 @@ import {
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
+ projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js";
function assertNear(actual, expected, tolerance, label) {
@@ -139,3 +140,29 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
});
+
+test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
+ const frontView = {
+ yaw: 0,
+ pitch: 0,
+ centerPixelX: 200,
+ centerPixelY: 120,
+ centerHorizontal: 0,
+ centerVertical: 0,
+ scale: 100,
+ };
+ const positiveX = projectArrayPosition3d([1, 0, 0], frontView);
+ const positiveZ = projectArrayPosition3d([0, 0, 1], frontView);
+ const positiveY = projectArrayPosition3d([0, 1, 0], frontView);
+ assertNear(positiveX.x, 300, 1e-12, "+X projects right");
+ assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
+ assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
+ assertNear(positiveZ.y, 20, 1e-12, "+Z FRONT projects upward");
+ assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth");
+
+ const edgeView = { ...frontView, pitch: Math.PI / 2 };
+ const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
+ const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView);
+ assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically");
+ assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis");
+});