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 TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
const DEFAULT_ORBIT = Object.freeze({ yaw: -Math.PI / 5, pitch: Math.PI / 4.5 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) {
function validPosition(position) {
return Array.isArray(position) &&
position.length >= 3 &&
Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1]));
Number.isFinite(Number(position[1])) &&
Number.isFinite(Number(position[2]));
}
function apertureDimensions(arrayDefinition) {
@@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
return best;
}
function rotateArrayPosition3d(position, yaw, pitch) {
const x = finiteNumber(position?.[0]);
const y = finiteNumber(position?.[1]);
const z = finiteNumber(position?.[2]);
const cosYaw = Math.cos(yaw);
const sinYaw = Math.sin(yaw);
const cosPitch = Math.cos(pitch);
const sinPitch = Math.sin(pitch);
const horizontal = cosYaw * x - sinYaw * y;
const away = sinYaw * x + cosYaw * y;
return {
horizontal,
vertical: z * cosPitch + away * sinPitch,
depth: z * sinPitch - away * cosPitch,
};
}
/** Project an array [x,y,z] position into a fitted 3D inspection viewport. */
export function projectArrayPosition3d(position, viewport) {
const rotated = rotateArrayPosition3d(position, viewport.yaw, viewport.pitch);
return {
x: viewport.centerPixelX + (rotated.horizontal - viewport.centerHorizontal) * viewport.scale,
y: viewport.centerPixelY - (rotated.vertical - viewport.centerVertical) * viewport.scale,
depth: rotated.depth,
};
}
function fitArrayViewport3d(width, height, model, zoom, orbit) {
const canvasWidth = Math.max(1, finiteNumber(width, 1));
const canvasHeight = Math.max(1, finiteNumber(height, 1));
const margins = resolvedMargins(canvasWidth, canvasHeight, {
left: 32,
right: 32,
top: 45,
bottom: 38,
});
const plot = {
x: margins.left,
y: margins.top,
width: Math.max(1, canvasWidth - margins.left - margins.right),
height: Math.max(1, canvasHeight - margins.top - margins.bottom),
};
const aperture = apertureDimensions(model.arrayDefinition);
const referenceSpan = Math.max(aperture.width, aperture.height, 0.000001);
const points = [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
[0, 0, referenceSpan * 0.72],
];
for (const microphone of model.arrayDefinition.microphones ?? []) {
if (validPosition(microphone?.position)) points.push(microphone.position);
}
const rotated = points.map((position) => rotateArrayPosition3d(position, orbit.yaw, orbit.pitch));
const horizontalValues = rotated.map((point) => point.horizontal);
const verticalValues = rotated.map((point) => point.vertical);
const horizontalMin = Math.min(...horizontalValues);
const horizontalMax = Math.max(...horizontalValues);
const verticalMin = Math.min(...verticalValues);
const verticalMax = Math.max(...verticalValues);
const horizontalSpan = Math.max(0.000001, horizontalMax - horizontalMin);
const verticalSpan = Math.max(0.000001, verticalMax - verticalMin);
const scale = Math.max(
Number.MIN_VALUE,
Math.min(plot.width / (horizontalSpan * 1.22), plot.height / (verticalSpan * 1.22)) * zoom,
);
return {
width: canvasWidth,
height: canvasHeight,
plot,
scale,
zoom,
yaw: orbit.yaw,
pitch: orbit.pitch,
centerHorizontal: (horizontalMin + horizontalMax) / 2,
centerVertical: (verticalMin + verticalMax) / 2,
centerPixelX: plot.x + plot.width / 2,
centerPixelY: plot.y + plot.height / 2,
referenceSpan,
};
}
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
@@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) {
context.textBaseline = "top";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
context.textBaseline = "top";
context.fillStyle = "rgba(101,242,191,.88)";
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 5, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 1.8, 0, Math.PI * 2);
context.fill();
context.fillText("+Z FRONT", viewport.plot.x + 20, viewport.plot.y + 5);
context.textAlign = "left";
return viewport;
}
function traceProjectedPositions(context, positions, viewport, close = false) {
context.beginPath();
positions.forEach((position, index) => {
const point = projectArrayPosition3d(position, viewport);
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
if (close) context.closePath();
}
function aperturePerimeter3d(model, sampleCount = 72) {
const aperture = apertureDimensions(model.arrayDefinition);
if (model.apertureShape !== "ellipse") {
return [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
];
}
return Array.from({ length: sampleCount }, (_, index) => {
const angle = index / sampleCount * Math.PI * 2;
return [
Math.cos(angle) * aperture.width / 2,
Math.sin(angle) * aperture.height / 2,
0,
];
});
}
function drawProjectedLine(context, start, end, viewport) {
const a = projectArrayPosition3d(start, viewport);
const b = projectArrayPosition3d(end, viewport);
context.beginPath();
context.moveTo(a.x, a.y);
context.lineTo(b.x, b.y);
context.stroke();
return b;
}
function drawArrayPlane3d(context, model, viewport) {
const perimeter = aperturePerimeter3d(model);
traceProjectedPositions(context, perimeter, viewport, true);
context.fillStyle = "rgba(101,242,191,.055)";
context.fill();
context.strokeStyle = "rgba(174,229,210,.72)";
context.lineWidth = 1.5;
context.setLineDash([7, 5]);
context.stroke();
context.setLineDash([]);
const aperture = apertureDimensions(model.arrayDefinition);
context.strokeStyle = "rgba(166,196,186,.14)";
context.lineWidth = 1;
for (const fraction of [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]) {
if (model.apertureShape === "ellipse") {
const bound = Math.sqrt(Math.max(0, 1 - fraction * fraction));
drawProjectedLine(
context,
[fraction * aperture.width / 2, -bound * aperture.height / 2, 0],
[fraction * aperture.width / 2, bound * aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-bound * aperture.width / 2, fraction * aperture.height / 2, 0],
[bound * aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
} else {
drawProjectedLine(
context,
[fraction * aperture.width / 2, -aperture.height / 2, 0],
[fraction * aperture.width / 2, aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-aperture.width / 2, fraction * aperture.height / 2, 0],
[aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
}
}
}
function drawAxis3d(context, viewport, endpoint, color, label) {
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 2;
const point = drawProjectedLine(context, [0, 0, 0], endpoint, viewport);
context.beginPath();
context.arc(point.x, point.y, 3, 0, Math.PI * 2);
context.fill();
context.font = "800 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText(label, point.x + 6, point.y);
}
function drawAxes3d(context, model, viewport) {
const aperture = apertureDimensions(model.arrayDefinition);
drawAxis3d(context, viewport, [aperture.width * 0.58, 0, 0], "#ff9e75", "+X");
drawAxis3d(context, viewport, [0, aperture.height * 0.58, 0], "#76b9ff", "+Y");
drawAxis3d(
context,
viewport,
[0, 0, viewport.referenceSpan * 0.72],
"#65f2bf",
"+Z FRONT",
);
}
function projectedMicrophones(model, viewport) {
return (model.arrayDefinition.microphones ?? [])
.filter((microphone) => validPosition(microphone?.position))
.map((microphone, index) => ({
microphone,
index,
point: projectArrayPosition3d(microphone.position, viewport),
}))
.sort((left, right) => left.point.depth - right.point.depth);
}
function drawMicrophones3d(context, model, viewport) {
const microphones = projectedMicrophones(model, viewport);
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
let selected = null;
for (const item of microphones) {
const { microphone, point } = item;
const isSelected = microphone.id === model.selectedMicrophoneId;
const inside = positionInsideAperture(
microphone.position,
model.arrayDefinition.aperture,
model.apertureShape,
);
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = "rgba(239,255,249,.7)";
context.lineWidth = 1;
context.stroke();
if (isSelected) selected = { microphone, point, radius };
}
if (selected) {
context.strokeStyle = "rgba(255,255,255,.94)";
context.lineWidth = 2;
context.beginPath();
context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2);
context.stroke();
drawSelectedLabel(context, selected.microphone, selected.point, viewport);
}
return microphones;
}
function drawArray3d(context, size, model, zoom, orbit) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
drawEmpty(context, size.width, size.height);
return null;
}
const viewport = fitArrayViewport3d(size.width, size.height, model, zoom, orbit);
context.fillStyle = PLOT_BACKGROUND;
context.fillRect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
drawArrayPlane3d(context, model, viewport);
drawAxes3d(context, model, viewport);
viewport.projectedMicrophones = drawMicrophones3d(context, model, viewport);
context.restore();
context.strokeStyle = "rgba(174,211,199,.28)";
context.lineWidth = 1;
context.strokeRect(
viewport.plot.x + 0.5,
viewport.plot.y + 0.5,
Math.max(0, viewport.plot.width - 1),
Math.max(0, viewport.plot.height - 1),
);
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.fillStyle = "rgba(205,225,218,.72)";
context.textAlign = "left";
context.fillText("3D INSPECT - drag to orbit", viewport.plot.x, 8);
context.textAlign = "right";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
return viewport;
}
function hitTestProjectedMicrophone(point, model, viewport, radiusPx = 12) {
let best = null;
for (const item of viewport?.projectedMicrophones ?? projectedMicrophones(model, viewport)) {
const distancePx = Math.hypot(item.point.x - point.x, item.point.y - point.y);
const isCloser = !best || distancePx < best.distancePx - 1e-9;
const isNearerTie = best &&
Math.abs(distancePx - best.distancePx) <= 1e-9 &&
item.point.depth > best.point.depth;
if (distancePx <= radiusPx && (isCloser || isNearerTie)) {
best = { ...item, id: item.microphone.id, distancePx };
}
}
return best;
}
function normalizedTool(tool) {
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
}
@@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let destroyed = false;
let zoom = 1;
let tool = "select";
let viewMode = "2d";
let orbit = { ...DEFAULT_ORBIT };
let orbitDrag = null;
let drag = null;
let pendingAdd = null;
@@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (canvas.dataset) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
canvas.dataset.viewMode = viewMode;
canvas.dataset.orbiting = String(Boolean(orbitDrag));
}
if (canvas.style) {
canvas.style.cursor = viewMode === "3d"
? orbitDrag ? "grabbing" : "grab"
: tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
function drawCurrent() {
frameId = 0;
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = drawArray(context, size, model, zoom, drag);
viewport = viewMode === "3d"
? drawArray3d(context, size, model, zoom, orbit)
: drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
@@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (viewport) return viewport;
if (!model?.arrayDefinition) return null;
const rect = canvas.getBoundingClientRect();
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
viewport = viewMode === "3d"
? fitArrayViewport3d(rect.width, rect.height, model, zoom, orbit)
: fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
return viewport;
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
const nextViewMode = nextModel?.viewMode === "3d" ? "3d" : "2d";
if (viewMode !== nextViewMode) {
viewMode = nextViewMode;
drag = null;
pendingAdd = null;
orbitDrag = null;
}
tool = normalizedTool(nextModel?.tool ?? tool);
if (viewMode === "3d") tool = "select";
viewport = null;
schedule();
}
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
@@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function fit() {
zoom = 1;
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
viewport = null;
schedule();
return zoom;
@@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX;
}
function finishOrbit(event) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
const pointerId = event?.pointerId ?? orbitDrag.pointerId;
orbitDrag = null;
try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch {
// Capture can be released by the browser before cancellation arrives.
}
updateCanvasAffordance();
schedule();
return true;
}
function onTouchStart(event) {
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
if (destroyed || !model?.arrayDefinition) return;
if (viewMode === "3d") {
event.preventDefault();
return;
}
if (tool !== "select") return;
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
const currentViewport = ensureViewport();
if (!touch || !currentViewport) return;
@@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
if (drag || pendingAdd || orbitDrag) return;
focus();
if (viewMode === "3d") {
event.preventDefault();
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
callbacks.onSelect?.(hit?.id ?? null);
orbitDrag = {
pointerId: event.pointerId,
lastClient: clientPoint(event),
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
updateCanvasAffordance();
schedule();
return;
}
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
@@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerMove(event) {
if (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event);
const dx = current.x - orbitDrag.lastClient.x;
const dy = current.y - orbitDrag.lastClient.y;
orbitDrag.lastClient = current;
orbit.yaw += dx * 0.01;
orbit.pitch = clamp(orbit.pitch - dy * 0.01, -Math.PI * 0.46, Math.PI * 0.46);
viewport = null;
event.preventDefault();
schedule();
return;
}
if (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return;
@@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (viewMode === "3d") {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === "f" || key === "r") {
event.preventDefault();
fit();
callbacks.onFit?.();
return;
}
if (event.key === "Escape") {
event.preventDefault();
finishOrbit();
return;
}
const orbitStep = event.shiftKey ? 0.18 : 0.06;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null;
schedule();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
orbit.pitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-Math.PI * 0.46,
Math.PI * 0.46,
);
viewport = null;
schedule();
}
return;
}
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") {
event.preventDefault();
@@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
function onLostPointerCapture(event) {
if (orbitDrag?.pointerId === event.pointerId) {
finishOrbit(event);
return;
}
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
}
@@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
orbitDrag = null;
drag = null;
pendingAdd = null;
},
+83 -35
View File
@@ -44,7 +44,7 @@
<div class="workspace-heading">
<div>
<p class="eyebrow">마이크 배치</p>
<h1>Array 평면 편집</h1>
<h1>Array 2D 편집 · 3D 검사</h1>
</div>
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
</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" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
</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="실행 취소와 다시 실행">
<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>
@@ -80,6 +84,7 @@
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
</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>
@@ -96,7 +101,7 @@
<p class="eyebrow">배열 분석</p>
<h2>수신 빔 패턴</h2>
</div>
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span>
<span id="beam-pattern-summary">1 kHz · XZ/YZ/XY 동시</span>
</summary>
<div class="beam-pattern-body">
@@ -106,18 +111,30 @@
<span>geometry-only</span>
</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">
<label class="field">
<span>아날로그 주파수 (Hz)</span>
<label class="field beam-frequency-field">
<span>아날로그 주파수 · 로그 슬라이더</span>
<div class="beam-frequency-control">
<input
id="beam-pattern-frequency"
type="number"
min="1"
max="1000000"
step="100"
value="1000"
inputmode="decimal"
type="range"
min="0"
max="6000"
step="1"
value="3000"
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 class="field">
<span>음속 (m/s)</span>
@@ -131,16 +148,8 @@
inputmode="decimal"
/>
</label>
<label class="field">
<span>360° 절단면</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>
<label class="field" data-beam-mode="cuts">
<span>단면별 조향각 (deg)</span>
<input
id="beam-pattern-steering-angle"
type="number"
@@ -150,8 +159,9 @@
value="0"
inputmode="decimal"
/>
<small>각 XZ/YZ/XY 평면 안에서 독립 조향</small>
</label>
<label class="field">
<label class="field" data-beam-mode="cuts">
<span>각도 샘플</span>
<select id="beam-pattern-samples">
<option value="auto">자동 · 위상 간격 기준</option>
@@ -162,6 +172,24 @@
<option value="5760">5,760 · 0.0625°</option>
</select>
</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">
<span>표시 하한 (dB)</span>
<select id="beam-pattern-floor">
@@ -185,24 +213,44 @@
현재 배열의 수신 array factor를 계산하고 있습니다.
</p>
<p class="beam-pattern-note" id="beam-pattern-model-note">
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴,
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다.
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 응답입니다. 세 평면은 각각
독립 조향한 great-circle 단면이며 3D 모드는 실제 구면 방향을 계산합니다. 근거리,
방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로 Nyquist 초과
주파수도 접지 않고 원래 파장으로 계산합니다.
</p>
</div>
<figure class="beam-pattern-figure">
<svg
id="beam-pattern-plot"
viewBox="0 0 520 520"
role="img"
aria-label="수신 빔 패턴 polar plot"
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
></svg>
<figcaption id="beam-pattern-peaks">
국소 최대를 계산하고 있습니다.
</figcaption>
<div class="beam-pattern-visuals">
<div class="beam-pattern-cut-grid" id="beam-pattern-cuts-view">
<figure class="beam-pattern-figure" data-cut="xz">
<div class="beam-pattern-figure-heading"><strong>XZ</strong><span>0° +Z · 90° +X</span></div>
<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>
<figcaption id="beam-pattern-caption-xz">계산 중…</figcaption>
</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>
</details>
+373 -94
View File
@@ -1,8 +1,12 @@
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js";
import {
computeReceiveBeamPattern,
computeReceiveBeamPatternSphere,
} from "/packages/acoustics-core/beam-pattern.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createArrayCanvas } from "./canvas-view.js";
import { createReceiveBeamPatternSurfaceView } from "./beam-pattern-surface-view.js";
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
import {
addMicrophone,
@@ -36,14 +40,22 @@ const requiredIds = [
"aperture-shape", "preset-type", "preset-count", "preset-columns",
"preset-rows", "preset-seed", "preset-minimum-spacing",
"generate-preset-button", "array-canvas", "select-tool-button",
"add-tool-button", "fit-view-button", "zoom-in-button", "zoom-out-button",
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
"remove-microphone-button", "validation-title", "validation-message",
"beam-pattern-panel", "beam-pattern-frequency",
"beam-pattern-speed-of-sound", "beam-pattern-cut",
"beam-pattern-steering-angle", "beam-pattern-samples",
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
"beam-pattern-panel", "beam-pattern-frequency", "beam-pattern-frequency-value",
"beam-pattern-speed-of-sound", "beam-pattern-steering-angle",
"beam-pattern-steering-azimuth", "beam-pattern-steering-elevation",
"beam-pattern-samples", "beam-pattern-surface-resolution",
"beam-pattern-floor", "beam-pattern-view-cuts-button",
"beam-pattern-view-3d-button", "beam-pattern-cuts-view",
"beam-pattern-surface-view", "beam-pattern-surface",
"beam-pattern-plot-xz", "beam-pattern-plot-yz", "beam-pattern-plot-xy",
"beam-pattern-caption-xz", "beam-pattern-caption-yz",
"beam-pattern-caption-xy", "beam-pattern-surface-caption",
"beam-pattern-status",
"beam-pattern-summary", "beam-pattern-wavelength",
"beam-pattern-resolution", "beam-pattern-steering-response",
"beam-pattern-hpbw", "beam-pattern-peaks",
@@ -62,7 +74,14 @@ const optionalIds = [
];
for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card");
const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]);
const BEAM_PATTERN_CUTS = Object.freeze(["xz", "yz", "xy"]);
const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
cut,
createReceiveBeamPatternView(elements["beam-pattern-plot-" + cut]),
]));
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
elements["beam-pattern-surface"],
);
const splitPane = createSplitPane({
container: elements["array-builder-layout"],
@@ -92,6 +111,8 @@ const state = {
dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
arrayViewMode: "2d",
beamPatternViewMode: "cuts",
beamPatternRenderFrame: 0,
beamPatternDirty: true,
beamPatternPreviewPending: false,
@@ -337,6 +358,7 @@ function dismissCanvasInstructions() {
}
function setTool(tool, options = {}) {
if (state.arrayViewMode === "3d" && tool === "add") return state.tool;
state.tool = tool === "add" ? "add" : "select";
elements["select-tool-button"].classList.toggle("is-active", state.tool === "select");
elements["add-tool-button"].classList.toggle("is-active", state.tool === "add");
@@ -351,12 +373,66 @@ function setTool(tool, options = {}) {
if (options.focus) canvasView.focus();
}
function setArrayViewMode(mode, options = {}) {
const resolved = mode === "3d" ? "3d" : "2d";
state.arrayViewMode = resolved;
if (resolved === "3d") state.tool = "select";
elements["array-view-2d-button"].classList.toggle("is-active", resolved === "2d");
elements["array-view-3d-button"].classList.toggle("is-active", resolved === "3d");
elements["array-view-2d-button"].setAttribute("aria-pressed", String(resolved === "2d"));
elements["array-view-3d-button"].setAttribute("aria-pressed", String(resolved === "3d"));
elements["add-tool-button"].disabled = resolved === "3d";
elements["select-tool-button"].disabled = resolved === "3d";
const instruction = elements["canvas-instructions"];
if (instruction) {
const title = instruction.querySelector("strong");
const detail = instruction.querySelector("span");
if (title) {
title.textContent = resolved === "3d"
? "드래그하여 배열을 회전하고 채널을 클릭해 선택하세요."
: "마이크를 클릭하거나 드래그해 위치를 편집하세요.";
}
if (detail) {
detail.textContent = resolved === "3d"
? "3D는 검사 전용입니다. 방향키로 회전하고 R로 초기화하며, 좌표 수정은 2D 또는 x/y/z 입력에서 합니다."
: "추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.";
}
instruction.classList.remove("is-hidden");
}
elements["canvas-front-badge"].textContent = resolved === "3d"
? "+Z FRONT · 초록 축"
: "+Z FRONT · 화면 바깥쪽";
elements["array-canvas"].setAttribute(
"aria-label",
resolved === "3d"
? "마이크 어레이 3D 검사 canvas, 드래그하여 회전"
: "마이크 어레이 평면 편집 canvas",
);
elements["array-canvas"].setAttribute(
"aria-keyshortcuts",
resolved === "3d"
? "ArrowLeft ArrowRight ArrowUp ArrowDown F R Escape"
: "V A F Escape Delete ArrowLeft ArrowRight ArrowUp ArrowDown",
);
setTool(state.tool);
canvasView.render(canvasState());
if (options.focus) canvasView.focus();
if (options.announce !== false) {
announceSelection(
resolved === "3d"
? "3D 검사 · +Z가 FRONT입니다."
: "2D 편집 · +Z FRONT는 화면 바깥 방향입니다.",
);
}
}
function canvasState() {
return {
arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
tool: state.tool,
viewMode: state.arrayViewMode,
snapStepM: state.snapEnabled ? state.snapStepM : 0,
};
}
@@ -496,13 +572,24 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
});
const beamPatternInputIds = [
"beam-pattern-frequency",
"beam-pattern-speed-of-sound",
"beam-pattern-cut",
"beam-pattern-steering-angle",
"beam-pattern-steering-azimuth",
"beam-pattern-steering-elevation",
"beam-pattern-samples",
"beam-pattern-surface-resolution",
"beam-pattern-floor",
];
const BEAM_FREQUENCY_LOG_STEPS_PER_DECADE = 1000;
const BEAM_PATTERN_LIVE_EVALUATION_BUDGET = 2_400_000;
const BEAM_PATTERN_FULL_EVALUATION_BUDGET = 16_000_000;
function beamPatternEvaluationBudget(preview, simultaneousViews = 1) {
const total = preview
? BEAM_PATTERN_LIVE_EVALUATION_BUDGET
: BEAM_PATTERN_FULL_EVALUATION_BUDGET;
return Math.floor(total / simultaneousViews);
}
function beamPatternNumber(id, label, options) {
const element = elements[id];
@@ -516,39 +603,35 @@ function beamPatternNumber(id, label, options) {
}
}
function readBeamPatternOptions(preview) {
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
const frequencyHz = beamPatternNumber(
"beam-pattern-frequency",
"아날로그 주파수",
{ exclusiveMinimum: 0, maximum: 1_000_000 },
function beamPatternFrequencyHz() {
const slider = elements["beam-pattern-frequency"];
const logarithmicPosition = finiteInput(slider, "아날로그 주파수 slider", {
minimum: 0,
maximum: 6000,
});
return Number(
Math.pow(10, logarithmicPosition / BEAM_FREQUENCY_LOG_STEPS_PER_DECADE)
.toPrecision(12),
);
}
function syncBeamPatternFrequency() {
const frequencyHz = beamPatternFrequencyHz();
const formatted = formatBeamFrequency(frequencyHz);
elements["beam-pattern-frequency-value"].textContent = formatted;
elements["beam-pattern-frequency"].setAttribute("aria-valuenow", String(frequencyHz));
elements["beam-pattern-frequency"].setAttribute("aria-valuetext", formatted);
return frequencyHz;
}
function readBeamPatternCommonOptions() {
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
const frequencyHz = syncBeamPatternFrequency();
const speedOfSound = beamPatternNumber(
"beam-pattern-speed-of-sound",
"음속",
{ minimum: 250, maximum: 400 },
);
const steeringAngleDeg = beamPatternNumber(
"beam-pattern-steering-angle",
"조향각",
{ minimum: -360, maximum: 360 },
);
const cut = elements["beam-pattern-cut"].value;
if (!["xy", "xz", "yz"].includes(cut)) {
elements["beam-pattern-cut"].setAttribute("aria-invalid", "true");
throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다.");
}
const sampleValue = elements["beam-pattern-samples"].value;
const configuredSampleCount = sampleValue === "auto"
? "auto"
: Number(sampleValue);
if (
configuredSampleCount !== "auto" &&
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
) {
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
}
const dbFloor = Number(elements["beam-pattern-floor"].value);
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
@@ -558,18 +641,73 @@ function readBeamPatternOptions(preview) {
array: state.arrayDefinition,
frequencyHz,
speedOfSound,
cut,
steeringAngleDeg,
sampleCount: preview
? Math.min(
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
720,
)
: configuredSampleCount,
dbFloor,
};
}
function readBeamPatternCutOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAngleDeg = beamPatternNumber(
"beam-pattern-steering-angle",
"단면별 조향각",
{ minimum: -360, maximum: 360 },
);
const sampleValue = elements["beam-pattern-samples"].value;
const configuredSampleCount = sampleValue === "auto" ? "auto" : Number(sampleValue);
if (
configuredSampleCount !== "auto" &&
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
) {
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
}
return {
...common,
steeringAngleDeg,
maximumEvaluations: beamPatternEvaluationBudget(
preview,
BEAM_PATTERN_CUTS.length,
),
sampleCount: preview
? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
: configuredSampleCount,
};
}
function readBeamPatternSurfaceOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAzimuthDeg = beamPatternNumber(
"beam-pattern-steering-azimuth",
"3D 조향 방위각",
{ minimum: -360, maximum: 360 },
);
const steeringElevationDeg = beamPatternNumber(
"beam-pattern-steering-elevation",
"3D 조향 고도각",
{ minimum: -90, maximum: 90 },
);
const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
if (
!Number.isFinite(configuredStepDeg) ||
configuredStepDeg < 1 ||
configuredStepDeg > 15 ||
360 % configuredStepDeg !== 0 ||
180 % configuredStepDeg !== 0
) {
elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
}
const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
return {
...common,
steeringAzimuthDeg,
steeringElevationDeg,
maximumEvaluations: beamPatternEvaluationBudget(preview),
azimuthSampleCount: Math.round(360 / stepDeg),
elevationSampleCount: Math.round(180 / stepDeg) + 1,
};
}
function formatBeamFrequency(frequencyHz) {
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
@@ -624,79 +762,197 @@ function setBeamPatternStatus(message, severity = "success") {
elements["beam-pattern-status"].dataset.severity = severity;
}
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
const options = readBeamPatternOptions(preview);
const pattern = computeReceiveBeamPattern(options);
beamPatternView.render(pattern);
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "true";
document.documentElement.dataset.beamPatternPreview = String(preview);
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
elements["beam-pattern-plot"].dataset.cut = pattern.cut;
elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
function renderBeamPatternCuts(preview, startedAt) {
const options = readBeamPatternCutOptions(preview);
const patterns = BEAM_PATTERN_CUTS.map((cut) =>
computeReceiveBeamPattern({ ...options, cut }));
for (const pattern of patterns) {
const cut = pattern.cut;
const plot = elements["beam-pattern-plot-" + cut];
beamPatternViews[cut].render(pattern);
plot.dataset.sampleCount = String(pattern.sampleCount);
plot.dataset.cut = cut;
plot.dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-caption-" + cut].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
(preview
? "live preview · 놓으면 HPBW 재계산"
: "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
const diagnostic = elements["beam-pattern-peaks"].querySelector(
'[data-cut="' + cut + '"] span',
);
if (diagnostic) {
diagnostic.textContent = preview
? "live raw samples · 놓으면 peak 재계산"
: describeBeamPatternPeaks(pattern);
}
}
const reference = patterns[0];
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(pattern.frequencyHz) + " · " +
pattern.cut.toUpperCase() + " · " +
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(pattern.wavelengthM);
elements["beam-pattern-resolution"].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " · " +
pattern.angularResolutionDeg.toFixed(4) + "°";
elements["beam-pattern-steering-response"].textContent =
formatBeamDecibels(pattern.metrics.steeringDb);
formatBeamWavelength(reference.wavelengthM);
elements["beam-pattern-resolution"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
pattern.sampleCount.toLocaleString("ko-KR") + " / " +
pattern.angularResolutionDeg.toFixed(3) + "°")
.join(" · ");
elements["beam-pattern-steering-response"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamDecibels(pattern.metrics.steeringDb))
.join(" · ");
elements["beam-pattern-hpbw"].textContent = preview
? "드래그 종료 후 계산"
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
? "slider/drag 종료 후 계산"
: patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
.join(" · ");
if (!preview) {
elements["beam-pattern-peaks"].textContent =
describeBeamPatternPeaks(pattern);
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs =
elapsedMs.toFixed(3);
if (!pattern.angularSampling.metricResolutionSatisfied) {
const unresolved = patterns.filter(
(pattern) => !pattern.angularSampling.metricResolutionSatisfied,
);
const missingCrossing = patterns.filter(
(pattern) =>
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing",
);
const evaluationCount = patterns.reduce(
(sum, pattern) => sum + pattern.evaluationCount,
0,
);
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (unresolved.length) {
setBeamPatternStatus(
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
" · " + elapsedMs.toFixed(1) + " ms",
unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
" raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else if (
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing"
) {
} else if (missingCrossing.length) {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
"에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준 충족 · " +
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
pattern.evaluationCount.toLocaleString("ko-KR") +
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
"세 평면 π/8 인접 위상 기준 충족 · " +
evaluationCount.toLocaleString("ko-KR") +
" microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPatternSurface(preview, startedAt) {
const options = readBeamPatternSurfaceOptions(preview);
const surface = computeReceiveBeamPatternSphere(options);
beamPatternSurfaceView.render(surface);
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
surface.steeringElevationDeg.toFixed(1) + "°";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(surface.wavelengthM);
elements["beam-pattern-resolution"].textContent =
surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
elements["beam-pattern-steering-response"].textContent =
"0.000 dB · analytic u=u₀";
elements["beam-pattern-hpbw"].textContent =
"3D 표면 · 평면 HPBW 미적용";
elements["beam-pattern-surface-caption"].textContent =
surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
" raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
(preview ? "live preview" : "drag/방향키로 회전");
if (!preview) {
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (!surface.angularSampling.resolutionCriterionSatisfied) {
setBeamPatternStatus(
"Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"3D 구면 π/8 cell 대각 위상 기준 충족 · " +
surface.evaluationCount.toLocaleString("ko-KR") +
" microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
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) {
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error";
document.documentElement.dataset.beamPatternPreview = "false";
document.documentElement.dataset.beamPatternPreview = String(preview);
const message = error instanceof Error ? error.message : String(error);
beamPatternView.renderError(message);
for (const view of Object.values(beamPatternViews)) view.renderError(message);
beamPatternSurfaceView.renderError(message);
setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—";
elements["beam-pattern-peaks"].textContent =
"빔 패턴 설정을 수정하면 다시 계산합니다.";
for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
}
}
}
function setBeamPatternViewMode(mode, options = {}) {
const resolved = mode === "surface" ? "surface" : "cuts";
state.beamPatternViewMode = resolved;
elements["beam-pattern-view-cuts-button"].classList.toggle(
"is-active",
resolved === "cuts",
);
elements["beam-pattern-view-3d-button"].classList.toggle(
"is-active",
resolved === "surface",
);
elements["beam-pattern-view-cuts-button"].setAttribute(
"aria-pressed",
String(resolved === "cuts"),
);
elements["beam-pattern-view-3d-button"].setAttribute(
"aria-pressed",
String(resolved === "surface"),
);
for (const field of document.querySelectorAll("[data-beam-mode]")) {
field.hidden = field.dataset.beamMode !== resolved;
}
elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
document.documentElement.dataset.beamPatternView = resolved;
state.beamPatternDirty = true;
if (options.render !== false) scheduleBeamPatternRender();
}
function scheduleBeamPatternRender(options = {}) {
const preview = options.preview === true;
@@ -1093,6 +1349,10 @@ elements["select-tool-button"].addEventListener("click", () =>
setTool("select", { focus: true, userInitiated: true }));
elements["add-tool-button"].addEventListener("click", () =>
setTool("add", { focus: true, userInitiated: true }));
elements["array-view-2d-button"].addEventListener("click", () =>
setArrayViewMode("2d", { focus: true }));
elements["array-view-3d-button"].addEventListener("click", () =>
setArrayViewMode("3d", { focus: true }));
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
@@ -1102,10 +1362,25 @@ elements["snap-enabled"].addEventListener("change", () => {
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
});
elements["beam-pattern-frequency"].addEventListener("input", () => {
syncBeamPatternFrequency();
scheduleBeamPatternRender({ preview: true });
});
elements["beam-pattern-frequency"].addEventListener("change", () =>
scheduleBeamPatternRender());
for (const id of beamPatternInputIds) {
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
if (elements[id] instanceof HTMLInputElement) {
elements[id].addEventListener("input", () =>
scheduleBeamPatternRender({ preview: true }));
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
} else {
elements[id].addEventListener("change", () => scheduleBeamPatternRender());
}
}
elements["beam-pattern-view-cuts-button"].addEventListener("click", () =>
setBeamPatternViewMode("cuts"));
elements["beam-pattern-view-3d-button"].addEventListener("click", () =>
setBeamPatternViewMode("surface"));
elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender();
@@ -1314,13 +1589,17 @@ window.addEventListener("unload", () => {
if (state.beamPatternRenderFrame) {
cancelAnimationFrame(state.beamPatternRenderFrame);
}
beamPatternView.destroy();
for (const view of Object.values(beamPatternViews)) view.destroy();
beamPatternSurfaceView.destroy();
splitPane.destroy();
canvasView.destroy();
});
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
renderAll("기본 4×3 배열");
canvasView.fit();
document.documentElement.dataset.appReady = "true";
+214 -11
View File
@@ -633,7 +633,9 @@ details > summary::-webkit-details-marker {
}
.beam-pattern-controls,
.beam-pattern-figure {
.beam-pattern-visuals,
.beam-pattern-figure,
.beam-pattern-surface-figure {
min-width: 0;
}
@@ -658,12 +660,59 @@ details > summary::-webkit-details-marker {
font-size: 0.55rem;
}
.beam-pattern-view-switch {
display: inline-flex;
gap: 5px;
margin-bottom: 11px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: #07110f;
}
.beam-pattern-view-switch .tool-button {
min-width: 82px;
}
.beam-pattern-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.beam-frequency-field {
grid-column: 1 / -1;
}
.beam-frequency-field > small,
.beam-pattern-fields .field > small {
margin-top: 5px;
color: var(--muted);
font-size: 0.56rem;
line-height: 1.4;
}
.beam-frequency-control {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(92px, auto);
align-items: center;
gap: 10px;
}
#beam-pattern-frequency {
min-width: 0;
padding-inline: 0;
accent-color: var(--mint);
cursor: ew-resize;
}
#beam-pattern-frequency-value {
color: var(--mint);
font: 760 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
text-align: right;
white-space: nowrap;
}
.beam-pattern-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -728,36 +777,139 @@ details > summary::-webkit-details-marker {
overflow-wrap: anywhere;
}
.beam-pattern-figure {
.beam-pattern-visuals {
display: grid;
min-width: 0;
align-content: start;
gap: 11px;
}
.beam-pattern-cut-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.beam-pattern-figure,
.beam-pattern-surface-figure {
display: grid;
align-content: start;
justify-items: center;
gap: 9px;
gap: 7px;
margin: 0;
}
#beam-pattern-plot {
.beam-pattern-figure-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 7px;
}
.beam-pattern-figure-heading strong {
color: #edf9f4;
font-size: 0.7rem;
}
.beam-pattern-figure-heading span {
min-width: 0;
color: var(--muted);
font: 620 0.53rem ui-monospace, SFMono-Regular, Consolas, monospace;
overflow-wrap: anywhere;
text-align: right;
}
#beam-pattern-plot-xz,
#beam-pattern-plot-yz,
#beam-pattern-plot-xy {
display: block;
width: min(100%, 580px);
width: 100%;
height: auto;
aspect-ratio: 1;
overflow: visible;
border: 1px solid var(--line);
border-radius: 14px;
border: 1px solid rgb(145 170 162 / 22%);
border-radius: 11px;
color: var(--mint);
background: #07110f;
}
.beam-pattern-figure figcaption {
width: min(100%, 580px);
min-height: 2.8em;
min-height: 3.8em;
margin: 0;
color: var(--muted);
font-size: 0.61rem;
font-size: 0.56rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.beam-pattern-surface-figure {
justify-items: stretch;
}
#beam-pattern-surface {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
border: 1px solid rgb(145 170 162 / 24%);
border-radius: 12px;
background: #07110f;
cursor: grab;
touch-action: none;
}
#beam-pattern-surface[data-orbiting="true"] {
cursor: grabbing;
}
#beam-pattern-surface:focus-visible {
outline: 2px solid var(--mint);
outline-offset: -3px;
}
.beam-pattern-surface-figure figcaption {
color: var(--muted);
font-size: 0.58rem;
line-height: 1.45;
}
.beam-pattern-diagnostics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.beam-pattern-diagnostics p {
min-width: 0;
margin: 0;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
background: rgb(255 255 255 / 1.5%);
font-size: 0.55rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.beam-pattern-diagnostics strong,
.beam-pattern-diagnostics span {
display: block;
}
.beam-pattern-diagnostics strong {
margin-bottom: 3px;
color: #cfe2db;
}
[data-beam-mode][hidden],
#beam-pattern-cuts-view[hidden],
#beam-pattern-surface-view[hidden],
#beam-pattern-peaks[hidden] {
display: none !important;
}
.beam-pattern-ring,
.beam-pattern-axis {
stroke: rgb(145 170 162 / 28%);
@@ -976,6 +1128,15 @@ body[data-array-tool="add"] #array-canvas,
cursor: crosshair;
}
#array-canvas[data-view-mode="3d"] {
cursor: grab;
touch-action: none;
}
#array-canvas[data-view-mode="3d"][data-orbiting="true"] {
cursor: grabbing;
}
.canvas-instructions {
position: absolute;
top: 24px;
@@ -1002,6 +1163,24 @@ body[data-array-tool="add"] #array-canvas,
display: none;
}
.canvas-front-badge {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 190px);
padding: 7px 10px;
border: 1px solid rgb(88 199 232 / 30%);
border-radius: 999px;
color: var(--cyan);
background: rgb(8 17 15 / 84%);
backdrop-filter: blur(10px);
font: 720 0.62rem ui-monospace, SFMono-Regular, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
.selection-status {
position: absolute;
right: 12px;
@@ -1093,6 +1272,17 @@ body[data-array-tool="add"] #array-canvas,
}
}
@container beam-pattern (max-width: 620px) {
.beam-pattern-cut-grid,
.beam-pattern-diagnostics {
grid-template-columns: minmax(0, 1fr);
}
.beam-pattern-figure figcaption {
min-height: 0;
}
}
@container beam-pattern (max-width: 460px) {
.beam-pattern-heading {
align-items: flex-start;
@@ -1106,6 +1296,19 @@ body[data-array-tool="add"] #array-canvas,
.beam-pattern-metrics {
grid-template-columns: minmax(0, 1fr);
}
.beam-frequency-field {
grid-column: auto;
}
.beam-frequency-control {
grid-template-columns: minmax(0, 1fr);
gap: 5px;
}
#beam-pattern-frequency-value {
text-align: left;
}
}
@media (max-width: 1280px) {
+317 -32
View File
@@ -32,6 +32,10 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function logarithmicFrequencyPosition(frequencyHz) {
return Math.round(Math.log10(frequencyHz) * 1000);
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
for (const file of files) transfer.items.add(file);
@@ -102,20 +106,46 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.documentElement.dataset.beamPatternReady === "true",
"기본 수신 빔 패턴 렌더",
);
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
?.getAttribute("d");
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
const beamCuts = ["xz", "yz", "xy"];
const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
cut,
documentObject.querySelector("#beam-pattern-plot-" + cut),
]));
const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
cut,
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
]));
for (const cut of beamCuts) {
const beamPlot = beamPlots[cut];
assert(
initialBeamPaths[cut]?.startsWith("M"),
cut.toUpperCase() + " polar response path is rendered",
);
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
"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(
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(
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
@@ -125,16 +155,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const initialSaveState = documentObject.querySelector("#save-state").textContent;
const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
49_125,
frequencySlider,
logarithmicFrequencyPosition(49_125),
"input",
);
await waitFor(
() => beamPlot.dataset.frequencyHz === "49125" &&
Number(beamPlot.dataset.sampleCount) > 720,
"above-Nyquist-style analog pattern with auto angular sampling",
() => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
beamCuts.every((cut) =>
Number(beamPlots[cut].dataset.frequencyHz) > 48_000 &&
beamPlots[cut].dataset.sampleCount === "720"),
"logarithmic slider input renders a bounded live preview in every cut",
);
const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow"));
assert(
previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000,
"log slider position maps to the intended above-Nyquist-style analog frequency",
);
assert(
documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"),
"log slider exposes its physical frequency value",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720),
"logarithmic slider change renders full auto resolution in every cut",
);
assert(
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
360,
);
await waitFor(
() => beamPlot.dataset.sampleCount === "360" &&
() => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족",
),
"coarse angular grid exposes unresolved metrics",
"coarse angular grid exposes unresolved metrics for the simultaneous cuts",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
documentObject.querySelector("#beam-pattern-speed-of-sound"),
"",
"input",
);
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
documentObject.querySelector("#beam-pattern-speed-of-sound"),
343,
"input",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
1_000,
frequencySlider,
logarithmicFrequencyPosition(1_000),
"input",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
beamPlot.dataset.frequencyHz === "1000",
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) =>
beamPlots[cut].dataset.frequencyHz === "1000" &&
Number(beamPlots[cut].dataset.sampleCount) >= 720),
"beam preview recovery",
);
assertEqual(
@@ -187,6 +245,87 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
initialSaveState,
"beam preview settings are outside Array dirty state",
);
documentObject.querySelector("#beam-pattern-view-3d-button").click();
const beamSurface = documentObject.querySelector("#beam-pattern-surface");
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
Number(beamSurface.dataset.sampleCount) > 0,
"3D beam surface render",
);
assert(
/^\d+x\d+$/.test(beamSurface.dataset.gridShape),
"3D beam canvas records its raw sphere grid",
);
assertEqual(
documentObject.querySelector("#beam-pattern-view-3d-button").getAttribute("aria-pressed"),
"true",
"3D beam view toggle is pressed",
);
assert(
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
"raw grid samples",
),
"3D beam caption identifies raw sampled directions",
);
assert(
documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes(
"dB-floor radial mapping",
),
"3D beam caption discloses the display-only radial mapping",
);
await nextPaint(windowObject);
const surfaceBeforeOrbit = beamSurface.toDataURL();
const beamSurfaceRect = beamSurface.getBoundingClientRect();
const beamOrbitPointerId = 71;
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 1,
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.5,
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.5,
}));
assertEqual(beamSurface.dataset.orbiting, "true", "3D beam drag begins orbiting");
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: -1,
buttons: 1,
clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.7,
clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.36,
}));
beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: beamOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 0,
}));
await nextPaint(windowObject);
assert(!beamSurface.dataset.orbiting, "3D beam drag finishes orbiting");
assert(
beamSurface.toDataURL() !== surfaceBeforeOrbit,
"3D beam surface bitmap changes after orbit rotation",
);
documentObject.querySelector("#beam-pattern-view-cuts-button").click();
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "cuts" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
documentObject.querySelector("#beam-pattern-surface-view").hidden,
"return to simultaneous polar cuts",
);
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
@@ -203,6 +342,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const canvas = documentObject.querySelector("#array-canvas");
assertEqual(canvas.dataset.viewMode, "2d", "array canvas starts in editable 2D mode");
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 화면 바깥쪽",
),
"2D canvas states the +Z front direction",
);
const coordinatesBeforeArray3d = selectedCoordinates(documentObject);
const microphoneCountBeforeArray3d = microphoneCount(documentObject);
documentObject.querySelector("#array-view-3d-button").click();
await waitFor(
() => canvas.dataset.viewMode === "3d" &&
canvas.getAttribute("aria-label").includes("3D 검사"),
"array 3D inspection mode",
);
assert(
documentObject.querySelector("#add-tool-button").disabled &&
documentObject.querySelector("#select-tool-button").disabled,
"array canvas edit tools are disabled in 3D inspection",
);
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 초록 축",
),
"3D canvas identifies the +Z front axis",
);
assert(
documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"),
"3D array guidance explains that coordinate editing is disabled",
);
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "a",
}));
await nextPaint(windowObject);
assertEqual(canvas.dataset.tool, "select", "A shortcut cannot enable add in 3D inspection");
assertEqual(
microphoneCount(documentObject),
microphoneCountBeforeArray3d,
"3D inspection cannot add a microphone",
);
assertEqual(
selectedCoordinates(documentObject).join(","),
coordinatesBeforeArray3d.join(","),
"3D inspection leaves microphone coordinates unchanged",
);
const array3dBeforeOrbit = canvas.toDataURL();
const array3dRect = canvas.getBoundingClientRect();
const arrayOrbitPointerId = 72;
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 1,
clientX: array3dRect.left + array3dRect.width * 0.5,
clientY: array3dRect.top + array3dRect.height * 0.5,
}));
assertEqual(canvas.dataset.orbiting, "true", "array 3D drag begins orbiting");
canvas.dispatchEvent(new windowObject.PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: -1,
buttons: 1,
clientX: array3dRect.left + array3dRect.width * 0.68,
clientY: array3dRect.top + array3dRect.height * 0.4,
}));
canvas.dispatchEvent(new windowObject.PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: arrayOrbitPointerId,
pointerType: "mouse",
button: 0,
buttons: 0,
}));
await nextPaint(windowObject);
assertEqual(canvas.dataset.orbiting, "false", "array 3D drag finishes orbiting");
assert(
canvas.toDataURL() !== array3dBeforeOrbit,
"array 3D canvas bitmap changes after orbit rotation",
);
documentObject.querySelector("#array-view-2d-button").click();
await waitFor(
() => canvas.dataset.viewMode === "2d" &&
canvas.getAttribute("aria-label").includes("평면 편집"),
"return to array 2D editing",
);
assert(
!documentObject.querySelector("#add-tool-button").disabled &&
!documentObject.querySelector("#select-tool-button").disabled,
"array canvas edit tools are restored in 2D",
);
assert(
documentObject.querySelector("#canvas-front-badge").textContent.includes(
"+Z FRONT · 화면 바깥쪽",
),
"returning to 2D restores its +Z front cue",
);
documentObject.querySelector(
'#microphone-list [data-microphone-id="mic-001"]',
).click();
assertEqual(
selectedCoordinates(documentObject).join(","),
coordinatesBeforeArray3d.join(","),
"2D edit resumes from the original microphone coordinates",
);
const defaultArray = createArrayDefinition();
const initialCanvasRect = canvas.getBoundingClientRect();
const initialViewport = fitArrayViewport(
@@ -234,9 +483,10 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
dispatchDragPointer("pointerup", 36, -24, 0);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
initialBeamPath,
"beam pattern follows edited microphone geometry",
beamCuts.every((cut) =>
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !==
initialBeamPaths[cut]),
"all beam-pattern cuts follow edited microphone geometry",
);
const draggedPosition = selectedCoordinates(documentObject);
assert(
@@ -789,28 +1039,63 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px",
);
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
.getBoundingClientRect();
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
const compactBeamPlotRects = beamCuts.map((cut) =>
beamPlots[cut].getBoundingClientRect());
assert(
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
"compact polar plot stays inside its analysis card",
compactBeamPlotRects.every((rect) =>
rect.left >= compactBeamCardRect.left - 1 &&
rect.right <= compactBeamCardRect.right + 1),
"all compact polar plots stay inside their analysis card",
);
assert(
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
"compact polar plot remains square",
compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2),
"all compact polar plots remain square",
);
assert(
compactBeamPlotRects.every(
(rect, index) => index === 0 || rect.top > compactBeamPlotRects[index - 1].bottom,
),
"compact XZ, YZ, and XY plots stack without overlap",
);
const compactBeamFields = [
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
].map((field) => field.getBoundingClientRect());
].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect());
assert(
compactBeamFields.every(
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
),
"compact beam controls stack without clipping",
);
documentObject.querySelector("#beam-pattern-view-3d-button").click();
await waitFor(
() => documentObject.documentElement.dataset.beamPatternView === "surface" &&
documentObject.documentElement.dataset.beamPatternReady === "true" &&
!documentObject.querySelector("#beam-pattern-surface-view").hidden &&
Number(beamSurface.dataset.sampleCount) > 0,
"compact 3D beam surface",
);
await nextPaint(windowObject);
const compactBeamSurfaceRect = beamSurface.getBoundingClientRect();
const compactBeamSurfaceCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
assert(
compactBeamSurfaceRect.left >= compactBeamSurfaceCardRect.left - 1 &&
compactBeamSurfaceRect.right <= compactBeamSurfaceCardRect.right + 1,
"compact 3D beam surface stays inside its analysis card",
);
assertNear(
compactBeamSurfaceRect.width / compactBeamSurfaceRect.height,
16 / 10,
0.03,
"compact 3D beam surface preserves its aspect ratio",
);
assert(
documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1,
"3D beam mode has no horizontal overflow at 390px",
);
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px",
+27
View File
@@ -7,6 +7,7 @@ import {
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js";
function assertNear(actual, expected, tolerance, label) {
@@ -139,3 +140,29 @@ test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
});
test("Array canvas 3D projection은 +Z FRONT와 right-handed XYZ를 보존한다", () => {
const frontView = {
yaw: 0,
pitch: 0,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
const positiveX = projectArrayPosition3d([1, 0, 0], frontView);
const positiveZ = projectArrayPosition3d([0, 0, 1], frontView);
const positiveY = projectArrayPosition3d([0, 1, 0], frontView);
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
assertNear(positiveX.y, 120, 1e-12, "+X keeps screen height");
assertNear(positiveZ.x, 200, 1e-12, "+Z stays centered horizontally");
assertNear(positiveZ.y, 20, 1e-12, "+Z FRONT projects upward");
assertNear(positiveY.depth, -1, 1e-12, "+Y supplies front-view depth");
const edgeView = { ...frontView, pitch: Math.PI / 2 };
const edgeY = projectArrayPosition3d([0, 1, 0], edgeView);
const edgeZ = projectArrayPosition3d([0, 0, 1], edgeView);
assertNear(edgeY.y, 20, 1e-12, "orbiting exposes +Y vertically");
assertNear(edgeZ.depth, 1, 1e-12, "orbiting moves +Z onto the depth axis");
});