feat: add live multi-plane and 3d array views

This commit is contained in:
2026-08-30 10:35:27 +09:00
parent dda72739a9
commit c289af366c
7 changed files with 1895 additions and 200 deletions
@@ -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;
},
};
}
+431 -7
View File
@@ -7,6 +7,7 @@ const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16; const MAX_ZOOM = 16;
const TOUCH_GESTURE_THRESHOLD_PX = 6; const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 }); 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"]); export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) {
function validPosition(position) { function validPosition(position) {
return Array.isArray(position) && return Array.isArray(position) &&
position.length >= 3 &&
Number.isFinite(Number(position[0])) && Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1])); Number.isFinite(Number(position[1])) &&
Number.isFinite(Number(position[2]));
} }
function apertureDimensions(arrayDefinition) { function apertureDimensions(arrayDefinition) {
@@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
return best; 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. */ /** Pure keyboard-to-array delta mapping used by the canvas controller. */
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) { export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
const requestedStep = Math.abs(finiteNumber(stepM, 0.001)); const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
@@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) {
context.textBaseline = "top"; context.textBaseline = "top";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8); context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left"; 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; 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) { function normalizedTool(tool) {
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select"; return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
} }
@@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let destroyed = false; let destroyed = false;
let zoom = 1; let zoom = 1;
let tool = "select"; let tool = "select";
let viewMode = "2d";
let orbit = { ...DEFAULT_ORBIT };
let orbitDrag = null;
let drag = null; let drag = null;
let pendingAdd = null; let pendingAdd = null;
@@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (canvas.dataset) { if (canvas.dataset) {
canvas.dataset.tool = tool; canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom); 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() { function drawCurrent() {
frameId = 0; frameId = 0;
if (destroyed) return; if (destroyed) return;
const size = prepareContext(canvas, context, view); 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(); updateCanvasAffordance();
} }
@@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (viewport) return viewport; if (viewport) return viewport;
if (!model?.arrayDefinition) return null; if (!model?.arrayDefinition) return null;
const rect = canvas.getBoundingClientRect(); 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; return viewport;
} }
function render(nextModel) { function render(nextModel) {
if (destroyed) return; if (destroyed) return;
model = nextModel; 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); tool = normalizedTool(nextModel?.tool ?? tool);
if (viewMode === "3d") tool = "select";
viewport = null; viewport = null;
schedule(); schedule();
} }
function setTool(nextTool) { function setTool(nextTool) {
const resolved = normalizedTool(nextTool); const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
if (resolved === tool) return tool; if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null; if (resolved !== "add") pendingAdd = null;
tool = resolved; tool = resolved;
@@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function fit() { function fit() {
zoom = 1; zoom = 1;
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
viewport = null; viewport = null;
schedule(); schedule();
return zoom; return zoom;
@@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX; 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) { 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 touch = event.changedTouches?.[0] ?? event.touches?.[0];
const currentViewport = ensureViewport(); const currentViewport = ensureViewport();
if (!touch || !currentViewport) return; if (!touch || !currentViewport) return;
@@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onPointerDown(event) { function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return; if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return; if (drag || pendingAdd || orbitDrag) return;
focus(); 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") { if (tool === "add") {
const position = constrainedEventPosition(event, 0); const position = constrainedEventPosition(event, 0);
if (position) { if (position) {
@@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) {
} }
function onPointerMove(event) { 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 (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null; if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return; return;
@@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onKeyDown(event) { function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return; 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; const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") { if (lowerKey === "a") {
event.preventDefault(); event.preventDefault();
@@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
} }
function onPointerUp(event) { function onPointerUp(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, false)) return; if (finishPendingAdd(event, false)) return;
finishDrag(event, false); finishDrag(event, false);
} }
function onPointerCancel(event) { function onPointerCancel(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, true)) return; if (finishPendingAdd(event, true)) return;
finishDrag(event, true); finishDrag(event, true);
} }
function onLostPointerCapture(event) { function onLostPointerCapture(event) {
if (orbitDrag?.pointerId === event.pointerId) {
finishOrbit(event);
return;
}
if (drag?.pointerId === event.pointerId) finishDrag(event, false); if (drag?.pointerId === event.pointerId) finishDrag(event, false);
} }
@@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("keydown", onKeyDown); canvas.removeEventListener("keydown", onKeyDown);
model = null; model = null;
viewport = null; viewport = null;
orbitDrag = null;
drag = null; drag = null;
pendingAdd = null; pendingAdd = null;
}, },
+83 -35
View File
@@ -44,7 +44,7 @@
<div class="workspace-heading"> <div class="workspace-heading">
<div> <div>
<p class="eyebrow">마이크 배치</p> <p class="eyebrow">마이크 배치</p>
<h1>Array 평면 편집</h1> <h1>Array 2D 편집 · 3D 검사</h1>
</div> </div>
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p> <p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
</div> </div>
@@ -54,6 +54,10 @@
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button> <button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button> <button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
</div> </div>
<div class="toolbar-group array-view-toggle" role="group" aria-label="배열 보기 방식">
<button class="tool-button is-active" id="array-view-2d-button" type="button" aria-pressed="true">2D 편집</button>
<button class="tool-button" id="array-view-3d-button" type="button" aria-pressed="false">3D 검사</button>
</div>
<div class="toolbar-group" aria-label="실행 취소와 다시 실행"> <div class="toolbar-group" aria-label="실행 취소와 다시 실행">
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button> <button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button> <button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
@@ -80,6 +84,7 @@
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong> <strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span> <span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
</div> </div>
<div class="canvas-front-badge" id="canvas-front-badge">+Z FRONT · 화면 바깥쪽</div>
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div> <div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
</div> </div>
@@ -96,7 +101,7 @@
<p class="eyebrow">배열 분석</p> <p class="eyebrow">배열 분석</p>
<h2>수신 빔 패턴</h2> <h2>수신 빔 패턴</h2>
</div> </div>
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span> <span id="beam-pattern-summary">1 kHz · XZ/YZ/XY 동시</span>
</summary> </summary>
<div class="beam-pattern-body"> <div class="beam-pattern-body">
@@ -106,18 +111,30 @@
<span>geometry-only</span> <span>geometry-only</span>
</p> </p>
<div class="beam-pattern-view-switch" role="group" aria-label="빔 패턴 보기 방식">
<button class="tool-button is-active" id="beam-pattern-view-cuts-button" type="button" aria-pressed="true">세 평면</button>
<button class="tool-button" id="beam-pattern-view-3d-button" type="button" aria-pressed="false">3D 표면</button>
</div>
<div class="beam-pattern-fields"> <div class="beam-pattern-fields">
<label class="field"> <label class="field beam-frequency-field">
<span>아날로그 주파수 (Hz)</span> <span>아날로그 주파수 · 로그 슬라이더</span>
<div class="beam-frequency-control">
<input <input
id="beam-pattern-frequency" id="beam-pattern-frequency"
type="number" type="range"
min="1" min="0"
max="1000000" max="6000"
step="100" step="1"
value="1000" value="3000"
inputmode="decimal" aria-valuemin="1"
aria-valuemax="1000000"
aria-valuenow="1000"
aria-valuetext="1.000 kHz"
/> />
<output id="beam-pattern-frequency-value" for="beam-pattern-frequency">1.000 kHz</output>
</div>
<small>1 Hz–1 MHz · 이동 중 live preview</small>
</label> </label>
<label class="field"> <label class="field">
<span>음속 (m/s)</span> <span>음속 (m/s)</span>
@@ -131,16 +148,8 @@
inputmode="decimal" inputmode="decimal"
/> />
</label> </label>
<label class="field"> <label class="field" data-beam-mode="cuts">
<span>360° 절단면</span> <span>단면별 조향각 (deg)</span>
<select id="beam-pattern-cut">
<option value="xz">XZ · 0° +Z / 90° +X</option>
<option value="yz">YZ · 0° +Z / 90° +Y</option>
<option value="xy">XY · 0° +X / 90° +Y</option>
</select>
</label>
<label class="field">
<span>조향각 (deg)</span>
<input <input
id="beam-pattern-steering-angle" id="beam-pattern-steering-angle"
type="number" type="number"
@@ -150,8 +159,9 @@
value="0" value="0"
inputmode="decimal" inputmode="decimal"
/> />
<small>각 XZ/YZ/XY 평면 안에서 독립 조향</small>
</label> </label>
<label class="field"> <label class="field" data-beam-mode="cuts">
<span>각도 샘플</span> <span>각도 샘플</span>
<select id="beam-pattern-samples"> <select id="beam-pattern-samples">
<option value="auto">자동 · 위상 간격 기준</option> <option value="auto">자동 · 위상 간격 기준</option>
@@ -162,6 +172,24 @@
<option value="5760">5,760 · 0.0625°</option> <option value="5760">5,760 · 0.0625°</option>
</select> </select>
</label> </label>
<label class="field" data-beam-mode="surface" hidden>
<span>3D 조향 방위각 (deg)</span>
<input id="beam-pattern-steering-azimuth" type="number" min="-360" max="360" step="1" value="0" inputmode="decimal" />
<small>XY에서 +X→+Y</small>
</label>
<label class="field" data-beam-mode="surface" hidden>
<span>3D 조향 고도각 (deg)</span>
<input id="beam-pattern-steering-elevation" type="number" min="-90" max="90" step="1" value="90" inputmode="decimal" />
<small>+90°가 +Z FRONT</small>
</label>
<label class="field" data-beam-mode="surface" hidden>
<span>3D 구면 표본</span>
<select id="beam-pattern-surface-resolution">
<option value="5">5° · 72×37</option>
<option value="3" selected>3° · 120×61</option>
<option value="2.5">2.5° · 144×73</option>
</select>
</label>
<label class="field"> <label class="field">
<span>표시 하한 (dB)</span> <span>표시 하한 (dB)</span>
<select id="beam-pattern-floor"> <select id="beam-pattern-floor">
@@ -185,24 +213,44 @@
현재 배열의 수신 array factor를 계산하고 있습니다. 현재 배열의 수신 array factor를 계산하고 있습니다.
</p> </p>
<p class="beam-pattern-note" id="beam-pattern-model-note"> <p class="beam-pattern-note" id="beam-pattern-model-note">
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴, 이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 응답입니다. 세 평면은 각각
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 독립 조향한 great-circle 단면이며 3D 모드는 실제 구면 방향을 계산합니다. 근거리,
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다. 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 Nyquist 초과
주파수도 접지 않고 원래 파장으로 계산합니다.
</p> </p>
</div> </div>
<figure class="beam-pattern-figure"> <div class="beam-pattern-visuals">
<svg <div class="beam-pattern-cut-grid" id="beam-pattern-cuts-view">
id="beam-pattern-plot" <figure class="beam-pattern-figure" data-cut="xz">
viewBox="0 0 520 520" <div class="beam-pattern-figure-heading"><strong>XZ</strong><span>0° +Z · 90° +X</span></div>
role="img" <svg id="beam-pattern-plot-xz" viewBox="0 0 520 520" role="img" aria-label="XZ 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-xz"></svg>
aria-label="수신 빔 패턴 polar plot" <figcaption id="beam-pattern-caption-xz">계산 중…</figcaption>
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
></svg>
<figcaption id="beam-pattern-peaks">
국소 최대를 계산하고 있습니다.
</figcaption>
</figure> </figure>
<figure class="beam-pattern-figure" data-cut="yz">
<div class="beam-pattern-figure-heading"><strong>YZ</strong><span>0° +Z · 90° +Y</span></div>
<svg id="beam-pattern-plot-yz" viewBox="0 0 520 520" role="img" aria-label="YZ 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-yz"></svg>
<figcaption id="beam-pattern-caption-yz">계산 중…</figcaption>
</figure>
<figure class="beam-pattern-figure" data-cut="xy">
<div class="beam-pattern-figure-heading"><strong>XY</strong><span>0° +X · 90° +Y</span></div>
<svg id="beam-pattern-plot-xy" viewBox="0 0 520 520" role="img" aria-label="XY 수신 빔 패턴 polar plot" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-caption-xy"></svg>
<figcaption id="beam-pattern-caption-xy">계산 중…</figcaption>
</figure>
</div>
<figure class="beam-pattern-surface-figure" id="beam-pattern-surface-view" hidden>
<div class="beam-pattern-figure-heading"><strong>3D 구면 응답</strong><span>drag하여 회전 · +Z FRONT</span></div>
<canvas id="beam-pattern-surface" tabindex="0" role="img" aria-label="3D 수신 빔 패턴 표면" aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-surface-caption"></canvas>
<figcaption id="beam-pattern-surface-caption">3D 표면을 준비하고 있습니다.</figcaption>
</figure>
<div class="beam-pattern-diagnostics" id="beam-pattern-peaks" aria-label="평면별 sampled peak 진단">
<p data-cut="xz"><strong>XZ</strong><span>계산 중…</span></p>
<p data-cut="yz"><strong>YZ</strong><span>계산 중…</span></p>
<p data-cut="xy"><strong>XY</strong><span>계산 중…</span></p>
</div>
</div>
</div> </div>
</details> </details>
+373 -94
View File
@@ -1,8 +1,12 @@
import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { validateArrayDefinition } from "/packages/contracts/validation.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 { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js"; import { createArrayCanvas } from "./canvas-view.js";
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
import { createReceiveBeamPatternView } from "./beam-pattern-view.js"; import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
import { import {
addMicrophone, addMicrophone,
@@ -36,14 +40,22 @@ const requiredIds = [
"aperture-shape", "preset-type", "preset-count", "preset-columns", "aperture-shape", "preset-type", "preset-count", "preset-columns",
"preset-rows", "preset-seed", "preset-minimum-spacing", "preset-rows", "preset-seed", "preset-minimum-spacing",
"generate-preset-button", "array-canvas", "select-tool-button", "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", "snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button", "microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
"remove-microphone-button", "validation-title", "validation-message", "remove-microphone-button", "validation-title", "validation-message",
"beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
"beam-pattern-speed-of-sound", "beam-pattern-cut", "beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
"beam-pattern-steering-angle", "beam-pattern-samples", "beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status", "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-summary", "beam-pattern-wavelength",
"beam-pattern-resolution", "beam-pattern-steering-response", "beam-pattern-resolution", "beam-pattern-steering-response",
"beam-pattern-hpbw", "beam-pattern-peaks", "beam-pattern-hpbw", "beam-pattern-peaks",
@@ -62,7 +74,14 @@ const optionalIds = [
]; ];
for (const id of optionalIds) elements[id] = maybeById(id); for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card"); 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({ const splitPane = createSplitPane({
container: elements["array-builder-layout"], container: elements["array-builder-layout"],
@@ -92,6 +111,8 @@ const state = {
dragSnapshot: null, dragSnapshot: null,
importRevision: 0, importRevision: 0,
baselineSignature: "", baselineSignature: "",
arrayViewMode: "2d",
beamPatternViewMode: "cuts",
beamPatternRenderFrame: 0, beamPatternRenderFrame: 0,
beamPatternDirty: true, beamPatternDirty: true,
beamPatternPreviewPending: false, beamPatternPreviewPending: false,
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
} }
function setTool(tool, options = {}) { function setTool(tool, options = {}) {
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
state.tool = tool === "add" ? "add" : "select"; state.tool = tool === "add" ? "add" : "select";
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select"); elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add"); elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
if (options.focus) canvasView.focus(); 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() { function canvasState() {
return { return {
arrayDefinition: state.arrayDefinition, arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape, apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId, selectedMicrophoneId: state.selectedMicrophoneId,
tool: state.tool, tool: state.tool,
viewMode: state.arrayViewMode,
snapStepM: state.snapEnabled ? state.snapStepM : 0, snapStepM: state.snapEnabled ? state.snapStepM : 0,
}; };
} }
@@ -496,13 +572,24 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
}); });
const beamPatternInputIds = [ const beamPatternInputIds = [
"beam-pattern-frequency",
"beam-pattern-speed-of-sound", "beam-pattern-speed-of-sound",
"beam-pattern-cut",
"beam-pattern-steering-angle", "beam-pattern-steering-angle",
"beam-pattern-steering-azimuth",
"beam-pattern-steering-elevation",
"beam-pattern-samples", "beam-pattern-samples",
"beam-pattern-surface-resolution",
"beam-pattern-floor", "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) { function beamPatternNumber(id, label, options) {
const element = elements[id]; const element = elements[id];
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
} }
} }
function readBeamPatternOptions(preview) { function beamPatternFrequencyHz() {
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid"); const slider = elements["beam-pattern-frequency"];
const frequencyHz = beamPatternNumber( const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
"beam-pattern-frequency", minimum: 0,
"아날로그 주파수", maximum: 6000,
{ exclusiveMinimum: 0, maximum: 1_000_000 }, });
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( const speedOfSound = beamPatternNumber(
"beam-pattern-speed-of-sound", "beam-pattern-speed-of-sound",
"음속", "음속",
{ minimum: 250, maximum: 400 }, { 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); const dbFloor = Number(elements["beam-pattern-floor"].value);
if (!Number.isFinite(dbFloor) || dbFloor >= 0) { if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true"); elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
array: state.arrayDefinition, array: state.arrayDefinition,
frequencyHz, frequencyHz,
speedOfSound, speedOfSound,
cut,
steeringAngleDeg,
sampleCount: preview
? Math.min(
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
720,
)
: configuredSampleCount,
dbFloor, 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) { function formatBeamFrequency(frequencyHz) {
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz"; if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz"; if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
@@ -624,79 +762,197 @@ function setBeamPatternStatus(message, severity = "success") {
elements["beam-pattern-status"].dataset.severity = severity; elements["beam-pattern-status"].dataset.severity = severity;
} }
function renderBeamPattern(preview = false) { function renderBeamPatternCuts(preview, startedAt) {
const startedAt = performance.now(); const options = readBeamPatternCutOptions(preview);
try { const patterns = BEAM_PATTERN_CUTS.map((cut) =>
const options = readBeamPatternOptions(preview); computeReceiveBeamPattern({ ...options, cut }));
const pattern = computeReceiveBeamPattern(options); for (const pattern of patterns) {
beamPatternView.render(pattern); const cut = pattern.cut;
state.beamPatternDirty = false; const plot = elements["beam-pattern-plot-" + cut];
document.documentElement.dataset.beamPatternReady = "true"; beamPatternViews[cut].render(pattern);
document.documentElement.dataset.beamPatternPreview = String(preview); plot.dataset.sampleCount = String(pattern.sampleCount);
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount); plot.dataset.cut = cut;
elements["beam-pattern-plot"].dataset.cut = pattern.cut; plot.dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-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 = elements["beam-pattern-summary"].textContent =
formatBeamFrequency(pattern.frequencyHz) + " · " + formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
pattern.cut.toUpperCase() + " · " + reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
elements["beam-pattern-wavelength"].textContent = elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(pattern.wavelengthM); formatBeamWavelength(reference.wavelengthM);
elements["beam-pattern-resolution"].textContent = elements["beam-pattern-resolution"].textContent = patterns
pattern.sampleCount.toLocaleString("ko-KR") + " · " + .map((pattern) =>
pattern.angularResolutionDeg.toFixed(4) + "°"; pattern.cut.toUpperCase() + " " +
elements["beam-pattern-steering-response"].textContent = pattern.sampleCount.toLocaleString("ko-KR") + " / " +
formatBeamDecibels(pattern.metrics.steeringDb); 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 elements["beam-pattern-hpbw"].textContent = preview
? "드래그 종료 후 계산" ? "slider/drag 종료 후 계산"
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth); : patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
.join(" · ");
if (!preview) { if (!preview) {
elements["beam-pattern-peaks"].textContent =
describeBeamPatternPeaks(pattern);
const elapsedMs = performance.now() - startedAt; const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs = const unresolved = patterns.filter(
elapsedMs.toFixed(3); (pattern) => !pattern.angularSampling.metricResolutionSatisfied,
if (!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( setBeamPatternStatus(
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " + unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) + " raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." + elapsedMs.toFixed(1) + " ms",
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
" · " + elapsedMs.toFixed(1) + " ms",
"warning", "warning",
); );
} else if ( } else if (missingCrossing.length) {
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing"
) {
setBeamPatternStatus( setBeamPatternStatus(
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " + missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms", "에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning", "warning",
); );
} else { } else {
setBeamPatternStatus( setBeamPatternStatus(
"π/8 인접 위상 간격 기준 충족 · " + "세 평면 π/8 인접 위상 기준 충족 · " +
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " + evaluationCount.toLocaleString("ko-KR") +
pattern.evaluationCount.toLocaleString("ko-KR") + " microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
"success", "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 {
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);
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
} catch (error) { } catch (error) {
state.beamPatternDirty = false; state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error"; 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); 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"); setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—"; 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 = {}) { function scheduleBeamPatternRender(options = {}) {
const preview = options.preview === true; const preview = options.preview === true;
@@ -1093,6 +1349,10 @@ elements["select-tool-button"].addEventListener("click", () =>
setTool("select", { focus: true, userInitiated: true })); setTool("select", { focus: true, userInitiated: true }));
elements["add-tool-button"].addEventListener("click", () => elements["add-tool-button"].addEventListener("click", () =>
setTool("add", { focus: true, userInitiated: true })); 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["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25)); elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8)); elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐"); 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) { for (const id of beamPatternInputIds) {
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change"; if (elements[id] instanceof HTMLInputElement) {
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender()); 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", () => { elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) { if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender(); scheduleBeamPatternRender();
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
if (state.beamPatternRenderFrame) { if (state.beamPatternRenderFrame) {
cancelAnimationFrame(state.beamPatternRenderFrame); cancelAnimationFrame(state.beamPatternRenderFrame);
} }
beamPatternView.destroy(); for (const view of Object.values(beamPatternViews)) view.destroy();
beamPatternSurfaceView.destroy();
splitPane.destroy(); splitPane.destroy();
canvasView.destroy(); canvasView.destroy();
}); });
elements["snap-enabled"].checked = state.snapEnabled; elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000); setNumber(elements["snap-step"], state.snapStepM * 1000);
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
renderAll("기본 4×3 배열"); renderAll("기본 4×3 배열");
canvasView.fit(); canvasView.fit();
document.documentElement.dataset.appReady = "true"; document.documentElement.dataset.appReady = "true";
+214 -11
View File
@@ -633,7 +633,9 @@ details > summary::-webkit-details-marker {
} }
.beam-pattern-controls, .beam-pattern-controls,
.beam-pattern-figure { .beam-pattern-visuals,
.beam-pattern-figure,
.beam-pattern-surface-figure {
min-width: 0; min-width: 0;
} }
@@ -658,12 +660,59 @@ details > summary::-webkit-details-marker {
font-size: 0.55rem; 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 { .beam-pattern-fields {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px; 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 { .beam-pattern-metrics {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -728,36 +777,139 @@ details > summary::-webkit-details-marker {
overflow-wrap: anywhere; 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; display: grid;
align-content: start; align-content: start;
justify-items: center; gap: 7px;
gap: 9px;
margin: 0; 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; display: block;
width: min(100%, 580px); width: 100%;
height: auto; height: auto;
aspect-ratio: 1; aspect-ratio: 1;
overflow: visible; overflow: visible;
border: 1px solid var(--line); border: 1px solid rgb(145 170 162 / 22%);
border-radius: 14px; border-radius: 11px;
color: var(--mint); color: var(--mint);
background: #07110f; background: #07110f;
} }
.beam-pattern-figure figcaption { .beam-pattern-figure figcaption {
width: min(100%, 580px); min-height: 3.8em;
min-height: 2.8em;
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: 0.61rem; font-size: 0.56rem;
line-height: 1.45; line-height: 1.45;
overflow-wrap: anywhere; 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-ring,
.beam-pattern-axis { .beam-pattern-axis {
stroke: rgb(145 170 162 / 28%); stroke: rgb(145 170 162 / 28%);
@@ -976,6 +1128,15 @@ body[data-array-tool="add"] #array-canvas,
cursor: crosshair; 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 { .canvas-instructions {
position: absolute; position: absolute;
top: 24px; top: 24px;
@@ -1002,6 +1163,24 @@ body[data-array-tool="add"] #array-canvas,
display: none; 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 { .selection-status {
position: absolute; position: absolute;
right: 12px; 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) { @container beam-pattern (max-width: 460px) {
.beam-pattern-heading { .beam-pattern-heading {
align-items: flex-start; align-items: flex-start;
@@ -1106,6 +1296,19 @@ body[data-array-tool="add"] #array-canvas,
.beam-pattern-metrics { .beam-pattern-metrics {
grid-template-columns: minmax(0, 1fr); 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) { @media (max-width: 1280px) {
+317 -32
View File
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
} }
function logarithmicFrequencyPosition(frequencyHz) {
return Math.round(Math.log10(frequencyHz) * 1000);
}
function dispatchFiles(windowObject, input, files) { function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer(); const transfer = new windowObject.DataTransfer();
for (const file of files) transfer.items.add(file); 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", () => documentObject.documentElement.dataset.beamPatternReady === "true",
"기본 수신 빔 패턴 렌더", "기본 수신 빔 패턴 렌더",
); );
const beamPlot = documentObject.querySelector("#beam-pattern-plot"); const beamCuts = ["xz", "yz", "xy"];
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response") const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
?.getAttribute("d"); cut,
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered"); 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( assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length, beamPlot.querySelectorAll(".beam-pattern-ring").length,
5, 5,
"polar plot has four inner dB rings and the 0 dB ring", 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",
); );
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
assert( assert(
Number(beamPlot.dataset.sampleCount) >= 720, Number(beamPlot.dataset.sampleCount) >= 720,
"auto angular sampling uses at least 720 directions", cut.toUpperCase() + " auto angular sampling uses at least 720 directions",
);
}
assert(
!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( assert(
documentObject.querySelector("#beam-pattern-model-note").textContent.includes( 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 initialSaveState = documentObject.querySelector("#save-state").textContent;
const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
dispatchValue( dispatchValue(
windowObject, windowObject,
documentObject.querySelector("#beam-pattern-frequency"), frequencySlider,
49_125, logarithmicFrequencyPosition(49_125),
"input", "input",
); );
await waitFor( await waitFor(
() => beamPlot.dataset.frequencyHz === "49125" && () => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
Number(beamPlot.dataset.sampleCount) > 720, beamCuts.every((cut) =>
"above-Nyquist-style analog pattern with auto angular sampling", 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( assert(
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"), documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
360, 360,
); );
await waitFor( await waitFor(
() => beamPlot.dataset.sampleCount === "360" && () => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes( documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족", "해상도 부족",
), ),
"coarse angular grid exposes unresolved metrics", "coarse angular grid exposes unresolved metrics for the simultaneous cuts",
); );
dispatchValue( dispatchValue(
windowObject, windowObject,
documentObject.querySelector("#beam-pattern-frequency"), documentObject.querySelector("#beam-pattern-speed-of-sound"),
"", "",
"input", "input",
); );
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
); );
dispatchValue( dispatchValue(
windowObject, windowObject,
documentObject.querySelector("#beam-pattern-samples"), documentObject.querySelector("#beam-pattern-speed-of-sound"),
"auto", 343,
"input",
); );
dispatchValue( dispatchValue(
windowObject, windowObject,
documentObject.querySelector("#beam-pattern-frequency"), frequencySlider,
1_000, logarithmicFrequencyPosition(1_000),
"input", "input",
); );
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
);
await waitFor( await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" && () => 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", "beam preview recovery",
); );
assertEqual( assertEqual(
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
initialSaveState, initialSaveState,
"beam preview settings are outside Array dirty state", "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(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual( assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId, 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"); 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 defaultArray = createArrayDefinition();
const initialCanvasRect = canvas.getBoundingClientRect(); const initialCanvasRect = canvas.getBoundingClientRect();
const initialViewport = fitArrayViewport( const initialViewport = fitArrayViewport(
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
dispatchDragPointer("pointerup", 36, -24, 0); dispatchDragPointer("pointerup", 36, -24, 0);
await waitFor( await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" && () => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !== beamCuts.every((cut) =>
initialBeamPath, beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
"beam pattern follows edited microphone geometry", initialBeamPaths[cut]),
"all beam-pattern cuts follow edited microphone geometry",
); );
const draggedPosition = selectedCoordinates(documentObject); const draggedPosition = selectedCoordinates(documentObject);
assert( assert(
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.documentElement.clientWidth + 1, documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px", "Array Builder has no horizontal overflow at 390px",
); );
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
.getBoundingClientRect();
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel") const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect(); .getBoundingClientRect();
const compactBeamPlotRects = beamCuts.map((cut) =>
beamPlots[cut].getBoundingClientRect());
assert( assert(
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 && compactBeamPlotRects.every((rect) =>
compactBeamPlotRect.right <= compactBeamCardRect.right + 1, rect.left >= compactBeamCardRect.left - 1 &&
"compact polar plot stays inside its analysis card", rect.right <= compactBeamCardRect.right + 1),
"all compact polar plots stay inside their analysis card",
); );
assert( assert(
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2, compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
"compact polar plot remains square", "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 = [ const compactBeamFields = [
...documentObject.querySelectorAll(".beam-pattern-fields > .field"), ...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
].map((field) => field.getBoundingClientRect()); ].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
assert( assert(
compactBeamFields.every( compactBeamFields.every(
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1, (rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
), ),
"compact beam controls stack without clipping", "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( assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px", "Array canvas remains usable at 390px",
+27
View File
@@ -7,6 +7,7 @@ import {
hitTestMicrophone, hitTestMicrophone,
keyboardNudgeForKey, keyboardNudgeForKey,
niceGridStep, niceGridStep,
projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js"; } from "/apps/array-builder/canvas-view.js";
function assertNear(actual, expected, tolerance, label) { 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("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored"); 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");
});