Compare commits

...

6 Commits

16 changed files with 3768 additions and 273 deletions
+20 -9
View File
@@ -107,13 +107,16 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
1. 시작 화면에서 **Array Builder**를 엽니다.
2. aperture 너비·높이와 프리셋을 배치할 **생성 경계**(사각형 또는 타원)를 정합니다.
3. **격자**, **선형**, **원형**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
3. **격자**, **선형**, **원형 링**, **Archimedean 나선**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
4. **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로 수정합니다.
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로
수정합니다.
목록에서는 삭제와 채널 순서 변경도 할 수 있습니다.
5. canvas 아래 **수신 빔 패턴**에서 아날로그 주파수·360° 절단면·조향각을 바꿔 polar 응답과
조건부 −3.0103 dB 폭, sampled peak 동률을 확인합니다.
6. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
5. 3D 배열 검사로 전환해 등각 또는 ±X/±Y/±Z 축 시점을 고르고 x/y/z 배치와
**+Z FRONT** 방향을 확인합니다.
6. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 live로 움직이며 XZ·YZ·XY 세 360° polar
응답과 3D 패턴, 조건부 −3.0103 dB 폭 및 sampled peak 동률을 확인합니다.
7. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
불러온 Array의 `sensitivityDb`, `phaseOffsetDeg`, `frequencyResponse` 보정 정보는 마이크 위치·ID나
채널 순서를 편집해도 보존됩니다. 프리셋 생성은 마이크 목록 전체를 새로 만드는 작업이므로 기존
@@ -121,7 +124,9 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
빔 패턴 주파수·절단면·조향각도 preview 설정이므로 Array JSON과 undo/dirty 상태에 들어가지 않습니다.
2D/3D 보기와 3D 축 시점, 빔 패턴 주파수·조향 방향·표시 모드도 preview 설정이므로 Array JSON과 undo/dirty
상태에 들어가지 않습니다. **+Z FRONT**는 좌표계의 전면 표식이며 무지향성 마이크에 별도 지향성을
부여하는 설정이 아닙니다.
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
### 2. Dataset Builder에서 Scene 만들기
@@ -343,9 +348,15 @@ Camera의 **배열 선택**에서 8개 fixture를 바로 불러와 같은 Scene/
의도된 예제 특성입니다.
새 배치를 직접 만들려면 [Array Builder](apps/array-builder/README.md)를 사용하세요. 저장소 fixture의
상세한 생성법은 [`assets/array-geometries/README.md`](assets/array-geometries/README.md)를 참고하세요.
Array Builder는 현재 배치의 geometry-only conventional receive array factor를 XZ·YZ·XY 360°
polar 단면으로 즉시 표시합니다. 이 계산은 ADC sample rate 없이 원 아날로그 파장을 사용하므로
Nyquist 초과 조건도 실행하며, 각도 해상도가 부족하면 raw 선을 남긴 채 HPBW·peak 해석을 보류합니다.
Array Builder는 현재 배치의 geometry-only conventional receive array factor
`B(u)=(1/M)Σ exp[j(2πf/c)(u-u0)·(r_m-r_c)]`를 XZ·YZ·XY 세 360° polar 단면으로 동시에
표시하며, 같은 식을 구면 방향에 평가한 3D 패턴도 제공합니다. 로그 slider는 넓은 아날로그 주파수
범위를 비율 단위로 live 탐색하며 각 위치에서 단일 narrowband 주파수 하나를 계산합니다. ADC sample
rate 없이 원 아날로그 파장을 사용하므로 Nyquist 초과 조건도 실행합니다. raw power는 `|B|²`, dB는
`10 log10(|B|²)`이고 표시를 위해 결과를 sampled maximum으로 다시 맞추지 않습니다. 각도 해상도가
부족하면 raw 응답을 남긴 채 HPBW·peak 해석을 보류합니다. polar plot은 직교 great-circle 단면이고
3D도 이산 구면 표본의 시각화입니다. 둘 다 far-field·단일 주파수 모델이며 near-field 거리나 광대역
주파수 결합을 추가하지 않습니다.
## 테스트와 benchmark
+53 -17
View File
@@ -2,8 +2,8 @@
Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이크 배열 편집기입니다. aperture
크기와 생성 경계를 정한 뒤 프리셋으로 시작하거나 마이크를 직접 추가·이동해 Acoustic Camera의
배열 입력으로 사용할 수 있습니다. 같은 화면에서 현재 좌표의 narrowband far-field 수신 빔 패턴도
확인할 수 있습니다.
배열 입력으로 사용할 수 있습니다. 같은 화면에서 배열을 2D로 편집하고 3D로 검사하며, 현재 좌표의
narrowband far-field 수신 빔 패턴을 XZ·YZ·XY 단면과 3D 보기로 확인할 수 있습니다.
## 열기
@@ -16,9 +16,12 @@ Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
4. canvas 아래 **수신 빔 패턴**에서 주파수·절단면·조향각에 따른 main lobe와 동률 응답을 확인합니다.
5. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
6. Acoustic Camera를 열어 **Array** 입력에서 저장한 `.array.json`을 선택합니다.
4. 3D 배열 보기로 전환해 등각 또는 **±X/±Y/±Z에서 보기**를 선택하고 z 배치와
**+Z FRONT** 방향이 의도와 같은지 검사합니다.
5. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 움직이며 세 polar 단면과 3D 패턴의 main
lobe 및 동률 응답을 확인합니다.
6. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
7. Acoustic Camera를 열어 **Array** 입력에서 저장한 `.array.json`을 선택합니다.
## 크기와 생성 경계
@@ -38,13 +41,16 @@ aperture 크기를 입력하는 것만으로 기존 좌표가 자동 확대·축
| --- | --- | --- |
| 격자 | 행·열, 경계 여백 | 아래 행부터 위로, 각 행은 왼쪽에서 오른쪽으로 생성 |
| 선형 | 마이크 수, x/y 축 | 선택한 중심축을 따라 일정 간격으로 생성 |
| 원형 | 마이크 수 | 사각 경계는 사각 둘레, 타원 경계는 타원 둘레에 균일 배치 |
| 나선 | 마이크 수 | golden angle을 사용하는 결정론적 Fermat 나선 |
| 원형 링 | 마이크 수 | 경계의 짧은 유효 반지름에 맞춘 실제 내접원 위에 동일 각도 배치 |
| Archimedean 나선 | 마이크 수, 회전 수 | 중심에서 시작해 <code>r∝θ</code>로 커지는 한 줄 곡선형 나선 |
| Fermat 나선 | 마이크 수 | golden angle과 <code>r∝√n</code>을 쓰는 면적 균등 해바라기형 배열 |
| 균일 랜덤 | 마이크 수, seed, 최소 간격 | 경계 내부에 seed로 재현되는 좌표 생성 |
**경계 여백**은 preset 좌표를 aperture 가장자리에서 안쪽으로 띄웁니다. 랜덤 배열은 요청한 최소
간격을 만족하는 배치를 제한된 횟수 안에 찾지 못하면 현재 배열을 바꾸지 않고 오류를 표시합니다.
같은 크기·형상·수·seed·간격을 사용하면 같은 랜덤 좌표가 생성됩니다.
원형과 두 나선은 사각/타원 경계 자체를 변형해 따라가지 않고, 어느 경계에도 들어가는 실제 내접원을
기준으로 만듭니다. 따라서 비정사각 aperture에서도 원이 타원이나 사각 둘레로 바뀌지 않습니다.
프리셋 생성은 현재 마이크 목록 전체를 교체합니다. 필요한 기존 좌표나 채널 보정이 있다면 먼저
Array JSON을 저장하세요.
@@ -61,6 +67,14 @@ Array JSON을 저장하세요.
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
배열 canvas의 2D 모드는 XY 좌표를 배치하는 편집기이고, 3D 모드는 x/y/z 관계와 앞뒤를 회전해 보는
검사기입니다. 좌표 추가·이동·삭제는 2D canvas나 숫자 필드에서 수행합니다. **+Z FRONT** 표시는
양의 z 쪽이 배열의 전면이라는 좌표 기준이며, 이상적 무지향성 마이크에 실제 전후 지향성을 추가하지
않습니다. 3D에서는 시점 선택기로 등각 또는 ±X/±Y/±Z 축에서 원점을 정확히 바라볼 수 있습니다.
여기서 “+X에서 보기”는 카메라가 +X 쪽에 있고 원점을 향한다는 뜻입니다. drag 또는 방향키로
회전하면 **자유 회전**으로 바뀌고, **R**이나 **화면 맞춤**으로 등각 시점을 복원합니다. 이 카메라
조작은 마이크 좌표, undo/dirty 상태와 저장 JSON을 바꾸지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
스크롤하려는 동작으로 채널이 생기지 않습니다.
@@ -70,25 +84,47 @@ Array JSON을 저장하세요.
## 수신 빔 패턴
배열 canvas 아래의 분석 카드는 ideal calibrated omnidirectional point microphone에 conventional
delay-and-sum을 적용한 geometry-only receive array factor를 표시합니다.
delay-and-sum을 적용한 geometry-only receive array factor를 표시합니다. 마이크 위치
<code>r_m</code>, 중심 <code>r_c</code>, 관찰·조향 방향 <code>u</code>·<code>u0</code>에 대해 사용하는
식은 다음과 같습니다.
<code>B(u)=(1/M)Σ exp[j(2πf/c)(u-u0)·(r_m-r_c)]</code>
<code>u=u0</code>이면 모든 위상이 맞아 <code>B=1</code>입니다. raw power는 <code>|B|²</code>, raw
dB는 <code>10 log10(|B|²)</code>이며 sampled maximum에 맞춰 결과를 다시 정규화하지 않습니다.
- **아날로그 주파수:** ADC bin이나 alias 주파수가 아닌 실제 음향 주파수입니다. 1 MHz까지
입력할 수 있으며 sample rate가 없으므로 Nyquist를 넘어도 원래 파장을 사용합니다.
- **절단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는 0°=+X·90°=+Y입니다.
- **조향각:** 선택한 360° great-circle 단면 안에서 conventional beamformer가 보는 방향입니다.
입력할 수 있으며 sample rate가 없으므로 Nyquist를 넘어도 원래 파장을 사용합니다. 로그 slider의
같은 이동 거리는 같은 Hz 차이가 아니라 같은 주파수 비율을 뜻하며, 옆의 출력값으로 실제 Hz를
확인합니다. 계산에는 현재 slider 위치의 주파수 하나만 사용됩니다.
- **세 polar 단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는
0°=+X·90°=+Y이며 세 plot을 함께 갱신합니다.
- **3D 패턴:** 같은 물리식을 구면 방향으로 평가해 세 단면 사이의 대각 lobe와 앞뒤 모호성을
회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다. 표시 시점은 등각 또는
±X/±Y/±Z에서 원점을 보는 방향으로 정렬할 수 있고, 시점 변경은 조향 방향·주파수·계산된 구면
응답을 변경하거나 다시 계산하지 않습니다.
- **조향 방향:** 표시된 좌표 규약에 따라 conventional beamformer가 보는 방향입니다.
- **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은
계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다.
- **표시 하한:** polar dB radius에만 적용됩니다. raw complex·power와 HPBW는 바뀌지 않습니다.
- **표시 하한:** polar와 3D의 dB 반지름 표시에만 적용됩니다. raw complex·power와 HPBW는 바뀌지
않습니다.
마이크를 드래그하는 동안 최대 720-angle preview를 갱신하고 놓으면 선택한 해상도로 다시 계산합니다.
주파수 slider를 움직이면 그 순간의 단일 narrowband 주파수에서 현재 패턴 보기가 live로 갱신됩니다.
이는 주파수 대역 적분이나 여러 map의 fusion이 아닙니다. 마이크를 드래그하는 동안에도 가벼운 preview를
갱신하고 놓으면 선택한 해상도로 다시 계산합니다.
live preview는 보기 전체에서 최대 240만 microphone-direction 평가, 전체 해상도 계산은 최대
1,600만 평가로 제한합니다. 세 polar 단면은 이 전체 예산을 동일하게 나눠 쓰므로, 큰 배열·고주파에서
자동 해상도가 예산을 넘으면 낮은 해상도로 몰래 계산하지 않고 오류를 표시합니다.
π/8 인접 pair phase-step 기준을 넘으면 raw polar 선은 유지하되 HPBW와 peak 해석을 **해상도
부족**으로 보류합니다. 기준을 만족한다는 것은 공개된 sampling 정책을 통과했다는 뜻이며 모든 연속
lobe 검출을 보장하지 않습니다.
−3.0103 dB 폭은 조향 lobe의 sampled bracket을 찾은 경우에만 표시합니다. sampled local maximum은
모두 나열하고 0 dB 동률을 임의로 하나 고르지 않지만, 이를 grating lobe나 sidelobe로 자동 분류하지
않습니다. plot은 3D 전체가 아니라 선택한 단면이고 near-field, 방·하우징·element directivity와
마이크 frequency response는 포함하지 않습니다.
않습니다. polar plot은 각각 하나의 great-circle 단면이고, 3D pattern도 이산 구면 표본의
시각화입니다. 계산은 한 번에 주파수 하나를 보는 narrowband·far-field plane-wave 모델이므로 source
거리, 구면파, 광대역 결합, near-field, 방·하우징·element directivity와 마이크 frequency response는
포함하지 않습니다. 3D 모드는 물리 모델의 범위를 늘리는 별도 알고리즘이 아닙니다.
수식, raw 결과 필드, workload 상한과 상태 의미는
[`../../docs/beam-pattern.md`](../../docs/beam-pattern.md)에 정리되어 있습니다.
@@ -112,8 +148,8 @@ frequency response도 함께 제거됩니다.
- 채널별 선택적 sensitivity, phase와 frequency-response curve
생성 경계의 사각형/타원 선택, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백, snap과
zoom, 빔 패턴 주파수·절단면·조향각·표본 수는 편집기 설정입니다. 표준 Array JSON에는 저장되지
않으며 Camera는 최종 aperture와 실제 마이크 좌표·순서를 사용합니다.
zoom, 2D/3D 보기, 빔 패턴 주파수·조향 방향·표본 수·표시 모드는 편집기 설정입니다. 표준 Array
JSON에는 저장되지 않으며 Camera는 최종 aperture와 실제 마이크 좌표·순서를 사용합니다.
## Camera로 전달하기
@@ -0,0 +1,470 @@
import { orbitForViewDirection } from "./orbit-view.js";
const MAX_PIXEL_RATIO = 3;
const MAX_SURFACE_VIEW_SAMPLES = 20_000;
const MAX_PITCH_RAD = Math.PI / 2;
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 scale = Math.min(size.width, size.height) * 0.39;
return {
x: size.width / 2 + rotated[0] * scale,
y: size.height / 2 - rotated[1] * scale,
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, callbacks = {}) {
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 viewDirection = "iso";
let { yaw, pitch } = orbitForViewDirection(viewDirection);
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 updateViewDirection(nextDirection) {
if (viewDirection === nextDirection) return;
viewDirection = nextDirection;
canvas.dataset.viewAxis = viewDirection;
callbacks.onViewDirectionChange?.(viewDirection);
}
function setViewDirection(nextDirection) {
const nextOrbit = orbitForViewDirection(nextDirection);
yaw = nextOrbit.yaw;
pitch = nextOrbit.pitch;
updateViewDirection(nextDirection);
schedule();
return viewDirection;
}
function markFreeOrbit() {
updateViewDirection("custom");
}
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();
const deltaX = event.clientX - orbit.x;
const deltaY = event.clientY - orbit.y;
const nextYaw = yaw + deltaX * 0.011;
const nextPitch = clamp(
pitch - deltaY * 0.011,
-MAX_PITCH_RAD,
MAX_PITCH_RAD,
);
if (nextYaw !== yaw || nextPitch !== pitch) markFreeOrbit();
yaw = nextYaw;
pitch = nextPitch;
orbit.x = event.clientX;
orbit.y = event.clientY;
schedule();
}
function onKeyDown(event) {
const step = event.shiftKey ? 15 : 5;
if (event.key === "ArrowLeft") {
markFreeOrbit();
yaw -= step * Math.PI / 180;
}
else if (event.key === "ArrowRight") {
markFreeOrbit();
yaw += step * Math.PI / 180;
}
else if (event.key === "ArrowUp") {
const nextPitch = clamp(
pitch + step * Math.PI / 180,
-MAX_PITCH_RAD,
MAX_PITCH_RAD,
);
if (nextPitch !== pitch) markFreeOrbit();
pitch = nextPitch;
} else if (event.key === "ArrowDown") {
const nextPitch = clamp(
pitch - step * Math.PI / 180,
-MAX_PITCH_RAD,
MAX_PITCH_RAD,
);
if (nextPitch !== pitch) markFreeOrbit();
pitch = nextPitch;
}
else if (event.key.toLowerCase() === "r") {
setViewDirection("iso");
} 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);
}
canvas.dataset.viewAxis = viewDirection;
schedule();
return {
render,
renderError,
setViewDirection,
resetOrbit() {
return setViewDirection("iso");
},
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;
},
};
}
+490 -8
View File
@@ -1,10 +1,12 @@
import { clampPositionToAperture, positionInsideAperture } from "./model.js";
import { orbitForViewDirection } from "./orbit-view.js";
const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const MAX_ORBIT_PITCH = Math.PI / 2;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
@@ -21,8 +23,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 +202,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 +531,239 @@ 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, viewDirection) {
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(
viewDirection === "custom"
? "3D INSPECT - FREE ORBIT"
: "3D INSPECT - VIEW FROM " + viewDirection.toUpperCase(),
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";
}
@@ -455,7 +772,8 @@ function normalizedTool(tool) {
* Create the editable microphone-array canvas.
*
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position),
* onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool), onFit().
* onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool),
* onViewDirectionChange(direction), onFit().
*/
export function createArrayCanvas(canvas, callbacks = {}) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
@@ -471,6 +789,10 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let destroyed = false;
let zoom = 1;
let tool = "select";
let viewMode = "2d";
let viewDirection = "iso";
let orbit = orbitForViewDirection(viewDirection);
let orbitDrag = null;
let drag = null;
let pendingAdd = null;
@@ -479,15 +801,24 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (canvas.dataset) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
canvas.dataset.viewMode = viewMode;
canvas.dataset.viewAxis = viewDirection;
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, viewDirection)
: drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
@@ -499,20 +830,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;
@@ -522,8 +863,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return tool;
}
function updateViewDirection(nextDirection) {
if (viewDirection === nextDirection) return;
viewDirection = nextDirection;
updateCanvasAffordance();
callbacks.onViewDirectionChange?.(viewDirection);
}
function setViewDirection(nextDirection) {
orbit = orbitForViewDirection(nextDirection);
updateViewDirection(nextDirection);
viewport = null;
schedule();
return viewDirection;
}
function markFreeOrbit() {
updateViewDirection("custom");
}
function fit() {
zoom = 1;
if (viewMode === "3d") {
orbit = orbitForViewDirection("iso");
updateViewDirection("iso");
}
viewport = null;
schedule();
return zoom;
@@ -586,8 +950,33 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX;
}
function finishOrbit(event, selectOnClick = false) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
const completed = orbitDrag;
const pointerId = event?.pointerId ?? completed.pointerId;
orbitDrag = null;
try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch {
// Capture can be released by the browser before cancellation arrives.
}
if (selectOnClick && !completed.moved) {
callbacks.onSelect?.(completed.hitId);
}
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 +990,30 @@ 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);
const startClient = clientPoint(event);
orbitDrag = {
pointerId: event.pointerId,
hitId: hit?.id ?? null,
startClient,
lastClient: startClient,
moved: false,
};
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 +1060,33 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerMove(event) {
if (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event);
if (!orbitDrag.moved) {
const distance = Math.hypot(
current.x - orbitDrag.startClient.x,
current.y - orbitDrag.startClient.y,
);
if (distance <= TOUCH_GESTURE_THRESHOLD_PX) return;
orbitDrag.moved = true;
}
const dx = current.x - orbitDrag.lastClient.x;
const dy = current.y - orbitDrag.lastClient.y;
orbitDrag.lastClient = current;
const nextYaw = orbit.yaw + dx * 0.01;
const nextPitch = clamp(
orbit.pitch - dy * 0.01,
-MAX_ORBIT_PITCH,
MAX_ORBIT_PITCH,
);
if (nextYaw !== orbit.yaw || nextPitch !== orbit.pitch) markFreeOrbit();
orbit.yaw = nextYaw;
orbit.pitch = nextPitch;
viewport = null;
event.preventDefault();
schedule();
return;
}
if (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return;
@@ -705,6 +1143,42 @@ 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();
markFreeOrbit();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null;
schedule();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const nextPitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-MAX_ORBIT_PITCH,
MAX_ORBIT_PITCH,
);
if (nextPitch !== orbit.pitch) markFreeOrbit();
orbit.pitch = nextPitch;
viewport = null;
schedule();
}
return;
}
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") {
event.preventDefault();
@@ -742,16 +1216,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishOrbit(event, true)) 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);
}
@@ -779,6 +1259,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return {
render,
setTool,
setViewDirection,
fit,
zoomBy,
focus,
@@ -798,6 +1279,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
orbitDrag = null;
drag = null;
pendingAdd = null;
},
+121 -41
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,23 @@
<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>
<label class="axis-view-control" id="array-axis-view-control" hidden>
<span>3D 시점</span>
<select id="array-axis-view" aria-label="배열 3D 시점, 선택한 축에서 원점 보기">
<option value="iso">등각</option>
<option value="+x">+X에서 보기</option>
<option value="-x">−X에서 보기</option>
<option value="+y">+Y에서 보기</option>
<option value="-y">−Y에서 보기</option>
<option value="+z">+Z FRONT에서 보기</option>
<option value="-z">−Z BACK에서 보기</option>
<option value="custom" disabled>자유 회전</option>
</select>
</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="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
@@ -80,6 +97,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 +114,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 +124,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>
<input
id="beam-pattern-frequency"
type="number"
min="1"
max="1000000"
step="100"
value="1000"
inputmode="decimal"
/>
<label class="field beam-frequency-field">
<span>아날로그 주파수 · 로그 슬라이더</span>
<div class="beam-frequency-control">
<input
id="beam-pattern-frequency"
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 +161,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 +172,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 +185,38 @@
<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" data-beam-mode="surface" hidden>
<span>3D 표시 시점</span>
<select id="beam-pattern-axis-view" aria-label="빔 패턴 3D 시점, 선택한 축에서 원점 보기">
<option value="iso">등각</option>
<option value="+x">+X에서 보기</option>
<option value="-x">−X에서 보기</option>
<option value="+y">+Y에서 보기</option>
<option value="-y">−Y에서 보기</option>
<option value="+z">+Z FRONT에서 보기</option>
<option value="-z">−Z BACK에서 보기</option>
<option value="custom" disabled>자유 회전</option>
</select>
<small>카메라만 변경 · 조향 방향과 응답 계산은 유지</small>
</label>
<label class="field">
<span>표시 하한 (dB)</span>
<select id="beam-pattern-floor">
@@ -185,24 +240,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>
</figure>
<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>
@@ -291,8 +366,9 @@
<select id="preset-type">
<option value="grid">격자</option>
<option value="linear">선형</option>
<option value="ring">원형</option>
<option value="spiral">나선</option>
<option value="ring">원형 링</option>
<option value="archimedean">나선 · Archimedean</option>
<option value="spiral">Fermat 나선 · 해바라기</option>
<option value="random">균일 랜덤</option>
</select>
</label>
@@ -308,6 +384,10 @@
<option value="y">세로 (y)</option>
</select>
</label>
<label class="field" id="preset-turns-field">
<span>나선 회전 수</span>
<input id="preset-turns" type="number" min="0.25" max="32" step="0.25" value="2.5" inputmode="decimal" />
</label>
<label class="field" id="preset-columns-field">
<span>열</span>
<input id="preset-columns" type="number" min="1" max="1024" step="1" value="4" inputmode="numeric" />
+473 -107
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,
@@ -34,16 +38,26 @@ const requiredIds = [
"array-builder-split-handle", "array-input", "export-button", "array-id",
"array-name", "array-description", "aperture-width", "aperture-height",
"aperture-shape", "preset-type", "preset-count", "preset-columns",
"preset-rows", "preset-seed", "preset-minimum-spacing",
"preset-rows", "preset-turns", "preset-seed", "preset-minimum-spacing",
"preset-hint",
"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",
"array-axis-view-control", "array-axis-view",
"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-axis-view",
"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 +76,19 @@ 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"],
{
onViewDirectionChange(direction) {
syncBeamViewDirection(direction);
},
},
);
const splitPane = createSplitPane({
container: elements["array-builder-layout"],
@@ -92,6 +118,8 @@ const state = {
dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
arrayViewMode: "2d",
beamPatternViewMode: "cuts",
beamPatternRenderFrame: 0,
beamPatternDirty: true,
beamPatternPreviewPending: false,
@@ -332,11 +360,60 @@ function announceSelection(message) {
if (elements["selection-status"]) elements["selection-status"].textContent = message;
}
const VIEW_DIRECTION_LABELS = Object.freeze({
iso: "등각",
"+x": "+X에서",
"-x": "−X에서",
"+y": "+Y에서",
"-y": "−Y에서",
"+z": "+Z FRONT에서",
"-z": "−Z BACK에서",
custom: "자유 회전",
});
function resolvedViewDirection(direction) {
return Object.hasOwn(VIEW_DIRECTION_LABELS, direction) ? direction : "iso";
}
function viewDirectionDescription(direction) {
const resolved = resolvedViewDirection(direction);
return resolved === "custom"
? VIEW_DIRECTION_LABELS.custom
: VIEW_DIRECTION_LABELS[resolved] + " 원점 보기";
}
function syncBeamViewDirection(direction) {
const resolved = resolvedViewDirection(direction);
elements["beam-pattern-axis-view"].value = resolved;
document.documentElement.dataset.beamPatternViewAxis = resolved;
elements["beam-pattern-surface"].setAttribute(
"aria-label",
"3D 수신 빔 패턴 표면 · " + viewDirectionDescription(resolved),
);
}
function syncArrayViewDirection(direction) {
const resolved = resolvedViewDirection(direction);
elements["array-axis-view"].value = resolved;
elements["array-canvas"].dataset.viewAxis = resolved;
document.documentElement.dataset.arrayViewAxis = resolved;
if (state.arrayViewMode === "3d") {
elements["canvas-front-badge"].textContent = resolved === "custom"
? "+Z FRONT · 자유 회전"
: "시점 " + VIEW_DIRECTION_LABELS[resolved] + " 원점 보기 · +Z FRONT";
elements["array-canvas"].setAttribute(
"aria-label",
"마이크 어레이 3D 검사 canvas · " + viewDirectionDescription(resolved),
);
}
}
function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden");
}
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 +428,68 @@ 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["array-axis-view-control"].hidden = 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",
);
if (resolved === "3d") syncArrayViewDirection(elements["array-axis-view"].value);
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,
};
}
@@ -490,19 +623,33 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
onToolChange(tool) {
setTool(tool, { userInitiated: true });
},
onViewDirectionChange(direction) {
syncArrayViewDirection(direction);
},
onFit() {
canvasView.fit();
},
});
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 +663,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 +701,73 @@ function readBeamPatternOptions(preview) {
array: state.arrayDefinition,
frequencyHz,
speedOfSound,
cut,
steeringAngleDeg,
sampleCount: preview
? Math.min(
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
720,
)
: configuredSampleCount,
dbFloor,
};
}
function readBeamPatternCutOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAngleDeg = beamPatternNumber(
"beam-pattern-steering-angle",
"단면별 조향각",
{ minimum: -360, maximum: 360 },
);
const sampleValue = elements["beam-pattern-samples"].value;
const configuredSampleCount = sampleValue === "auto" ? "auto" : Number(sampleValue);
if (
configuredSampleCount !== "auto" &&
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
) {
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
}
return {
...common,
steeringAngleDeg,
maximumEvaluations: beamPatternEvaluationBudget(
preview,
BEAM_PATTERN_CUTS.length,
),
sampleCount: preview
? Math.min(configuredSampleCount === "auto" ? 720 : configuredSampleCount, 720)
: configuredSampleCount,
};
}
function readBeamPatternSurfaceOptions(preview) {
const common = readBeamPatternCommonOptions();
const steeringAzimuthDeg = beamPatternNumber(
"beam-pattern-steering-azimuth",
"3D 조향 방위각",
{ minimum: -360, maximum: 360 },
);
const steeringElevationDeg = beamPatternNumber(
"beam-pattern-steering-elevation",
"3D 조향 고도각",
{ minimum: -90, maximum: 90 },
);
const configuredStepDeg = Number(elements["beam-pattern-surface-resolution"].value);
if (
!Number.isFinite(configuredStepDeg) ||
configuredStepDeg < 1 ||
configuredStepDeg > 15 ||
360 % configuredStepDeg !== 0 ||
180 % configuredStepDeg !== 0
) {
elements["beam-pattern-surface-resolution"].setAttribute("aria-invalid", "true");
throw new Error("3D 구면 표본 간격이 올바르지 않습니다.");
}
const stepDeg = preview ? Math.max(6, configuredStepDeg) : configuredStepDeg;
return {
...common,
steeringAzimuthDeg,
steeringElevationDeg,
maximumEvaluations: beamPatternEvaluationBudget(preview),
azimuthSampleCount: Math.round(360 / stepDeg),
elevationSampleCount: Math.round(180 / stepDeg) + 1,
};
}
function formatBeamFrequency(frequencyHz) {
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
@@ -624,80 +822,198 @@ function setBeamPatternStatus(message, severity = "success") {
elements["beam-pattern-status"].dataset.severity = severity;
}
function renderBeamPatternCuts(preview, startedAt) {
const options = readBeamPatternCutOptions(preview);
const patterns = BEAM_PATTERN_CUTS.map((cut) =>
computeReceiveBeamPattern({ ...options, cut }));
for (const pattern of patterns) {
const cut = pattern.cut;
const plot = elements["beam-pattern-plot-" + cut];
beamPatternViews[cut].render(pattern);
plot.dataset.sampleCount = String(pattern.sampleCount);
plot.dataset.cut = cut;
plot.dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-caption-" + cut].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " samples · " +
(preview
? "live preview · 놓으면 HPBW 재계산"
: "HPBW " + formatBeamHpbw(pattern.metrics.halfPowerBeamwidth));
const diagnostic = elements["beam-pattern-peaks"].querySelector(
'[data-cut="' + cut + '"] span',
);
if (diagnostic) {
diagnostic.textContent = preview
? "live raw samples · 놓으면 peak 재계산"
: describeBeamPatternPeaks(pattern);
}
}
const reference = patterns[0];
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(reference.frequencyHz) + " · XZ/YZ/XY 동시 · " +
reference.steeringAngleDeg.toFixed(1) + "° 독립 조향";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(reference.wavelengthM);
elements["beam-pattern-resolution"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
pattern.sampleCount.toLocaleString("ko-KR") + " / " +
pattern.angularResolutionDeg.toFixed(3) + "°")
.join(" · ");
elements["beam-pattern-steering-response"].textContent = patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamDecibels(pattern.metrics.steeringDb))
.join(" · ");
elements["beam-pattern-hpbw"].textContent = preview
? "slider/drag 종료 후 계산"
: patterns
.map((pattern) =>
pattern.cut.toUpperCase() + " " +
formatBeamHpbw(pattern.metrics.halfPowerBeamwidth))
.join(" · ");
if (!preview) {
const elapsedMs = performance.now() - startedAt;
const unresolved = patterns.filter(
(pattern) => !pattern.angularSampling.metricResolutionSatisfied,
);
const missingCrossing = patterns.filter(
(pattern) =>
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing",
);
const evaluationCount = patterns.reduce(
(sum, pattern) => sum + pattern.evaluationCount,
0,
);
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (unresolved.length) {
setBeamPatternStatus(
unresolved.map((pattern) => pattern.cut.toUpperCase()).join("/") +
" raw polar 표본은 표시했지만 π/8 인접 위상 기준을 넘겨 HPBW·peak 해석을 보류합니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else if (missingCrossing.length) {
setBeamPatternStatus(
missingCrossing.map((pattern) => pattern.cut.toUpperCase()).join("/") +
"에서 sampled half-power crossing이 없어 전 방향 HPBW라고 단정하지 않습니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"세 평면 π/8 인접 위상 기준 충족 · " +
evaluationCount.toLocaleString("ko-KR") +
" microphone-angle evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPatternSurface(preview, startedAt) {
const options = readBeamPatternSurfaceOptions(preview);
const surface = computeReceiveBeamPatternSphere(options);
beamPatternSurfaceView.render(surface);
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(surface.frequencyHz) + " · 3D · az " +
surface.steeringAzimuthDeg.toFixed(1) + "° / el " +
surface.steeringElevationDeg.toFixed(1) + "°";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(surface.wavelengthM);
elements["beam-pattern-resolution"].textContent =
surface.angularSampling.azimuthStepDeg.toFixed(3) + "° az · " +
surface.angularSampling.elevationStepDeg.toFixed(3) + "° el · " +
surface.sampleCount.toLocaleString("ko-KR") + " grid samples";
elements["beam-pattern-steering-response"].textContent =
"0.000 dB · analytic u=u₀";
elements["beam-pattern-hpbw"].textContent =
"3D 표면 · 평면 HPBW 미적용";
elements["beam-pattern-surface-caption"].textContent =
surface.azimuthSampleCount + "×" + surface.elevationSampleCount +
" raw grid samples · dB-floor radial mapping · response interpolation 없음 · " +
(preview ? "live preview" : "drag/방향키로 회전");
if (!preview) {
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs = elapsedMs.toFixed(3);
if (!surface.angularSampling.resolutionCriterionSatisfied) {
setBeamPatternStatus(
"Raw 3D 표면은 표시했습니다. 구면 cell 대각 위상 변화 " +
surface.angularSampling.maximumCellDiagonalPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 미세 lobe 해석은 미확정입니다. · " +
elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"3D 구면 π/8 cell 대각 위상 기준 충족 · " +
surface.evaluationCount.toLocaleString("ko-KR") +
" microphone-direction evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
}
function renderBeamPattern(preview = false) {
const startedAt = performance.now();
try {
const options = readBeamPatternOptions(preview);
const pattern = computeReceiveBeamPattern(options);
beamPatternView.render(pattern);
if (state.beamPatternViewMode === "surface") {
renderBeamPatternSurface(preview, startedAt);
} else {
renderBeamPatternCuts(preview, startedAt);
}
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "true";
document.documentElement.dataset.beamPatternPreview = String(preview);
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
elements["beam-pattern-plot"].dataset.cut = pattern.cut;
elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
elements["beam-pattern-summary"].textContent =
formatBeamFrequency(pattern.frequencyHz) + " · " +
pattern.cut.toUpperCase() + " · " +
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
elements["beam-pattern-wavelength"].textContent =
formatBeamWavelength(pattern.wavelengthM);
elements["beam-pattern-resolution"].textContent =
pattern.sampleCount.toLocaleString("ko-KR") + " · " +
pattern.angularResolutionDeg.toFixed(4) + "°";
elements["beam-pattern-steering-response"].textContent =
formatBeamDecibels(pattern.metrics.steeringDb);
elements["beam-pattern-hpbw"].textContent = preview
? "드래그 종료 후 계산"
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
if (!preview) {
elements["beam-pattern-peaks"].textContent =
describeBeamPatternPeaks(pattern);
const elapsedMs = performance.now() - startedAt;
document.documentElement.dataset.beamPatternComputeMs =
elapsedMs.toFixed(3);
if (!pattern.angularSampling.metricResolutionSatisfied) {
setBeamPatternStatus(
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
" · " + elapsedMs.toFixed(1) + " ms",
"warning",
);
} else if (
pattern.metrics.halfPowerBeamwidth.status ===
"no-sampled-half-power-crossing"
) {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
"warning",
);
} else {
setBeamPatternStatus(
"π/8 인접 위상 간격 기준 충족 · " +
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
pattern.evaluationCount.toLocaleString("ko-KR") +
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
"success",
);
}
}
document.documentElement.dataset.beamPatternView = state.beamPatternViewMode;
} catch (error) {
state.beamPatternDirty = false;
document.documentElement.dataset.beamPatternReady = "error";
document.documentElement.dataset.beamPatternPreview = "false";
document.documentElement.dataset.beamPatternPreview = String(preview);
const message = error instanceof Error ? error.message : String(error);
beamPatternView.renderError(message);
for (const view of Object.values(beamPatternViews)) view.renderError(message);
beamPatternSurfaceView.renderError(message);
setBeamPatternStatus(message, "error");
elements["beam-pattern-hpbw"].textContent = "—";
elements["beam-pattern-peaks"].textContent =
"빔 패턴 설정을 수정하면 다시 계산합니다.";
for (const diagnostic of elements["beam-pattern-peaks"].querySelectorAll("span")) {
diagnostic.textContent = "설정을 수정하면 다시 계산합니다.";
}
}
}
function setBeamPatternViewMode(mode, options = {}) {
const resolved = mode === "surface" ? "surface" : "cuts";
state.beamPatternViewMode = resolved;
elements["beam-pattern-view-cuts-button"].classList.toggle(
"is-active",
resolved === "cuts",
);
elements["beam-pattern-view-3d-button"].classList.toggle(
"is-active",
resolved === "surface",
);
elements["beam-pattern-view-cuts-button"].setAttribute(
"aria-pressed",
String(resolved === "cuts"),
);
elements["beam-pattern-view-3d-button"].setAttribute(
"aria-pressed",
String(resolved === "surface"),
);
for (const field of document.querySelectorAll("[data-beam-mode]")) {
field.hidden = field.dataset.beamMode !== resolved;
}
elements["beam-pattern-cuts-view"].hidden = resolved !== "cuts";
elements["beam-pattern-peaks"].hidden = resolved !== "cuts";
elements["beam-pattern-surface-view"].hidden = resolved !== "surface";
document.documentElement.dataset.beamPatternView = resolved;
state.beamPatternDirty = true;
if (options.render !== false) scheduleBeamPatternRender();
}
function scheduleBeamPatternRender(options = {}) {
const preview = options.preview === true;
state.beamPatternDirty = true;
@@ -873,6 +1189,7 @@ function syncPresetControls() {
const grid = type === "grid";
const random = type === "random";
const linear = type === "linear";
const archimedean = type === "archimedean";
for (const element of [elements["preset-columns"], elements["preset-rows"]]) {
element.closest(".field")?.toggleAttribute("hidden", !grid);
}
@@ -880,6 +1197,16 @@ function syncPresetControls() {
elements["preset-seed"].closest(".field")?.toggleAttribute("hidden", !random);
elements["preset-minimum-spacing"].closest(".field")?.toggleAttribute("hidden", !random);
elements["preset-axis"]?.closest(".field")?.toggleAttribute("hidden", !linear);
elements["preset-turns"].closest(".field")?.toggleAttribute("hidden", !archimedean);
const hints = {
grid: "격자는 아래에서 위로, 각 행은 왼쪽에서 오른쪽 순서로 채널을 만듭니다.",
linear: "선택한 x/y 축 위에 마이크를 같은 간격으로 놓습니다.",
ring: "짧은 유효 반지름에 맞춘 실제 내접원 위에 같은 각도 간격으로 놓습니다.",
archimedean: "r∝θ인 한 줄 나선입니다. 회전 수를 늘리면 같은 반경 안에서 더 많이 감깁니다.",
spiral: "golden angle과 r∝√n을 쓰는 면적 균등 Fermat 배열입니다. 점만 보면 해바라기형으로 보입니다.",
random: "seed로 재현되는 균일 랜덤 좌표를 요청한 최소 간격으로 생성합니다.",
};
elements["preset-hint"].textContent = hints[type] ?? hints.grid;
}
function syncHistoryButtons() {
@@ -1021,6 +1348,12 @@ function presetOptions() {
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
}
if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x";
if (type === "archimedean") {
options.turns = finiteInput(elements["preset-turns"], "나선 회전 수", {
minimum: 0.25,
maximum: 32,
});
}
if (type === "random") {
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
options.minimumSpacingM = finiteInput(
@@ -1093,6 +1426,15 @@ 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["array-axis-view"].addEventListener("change", () => {
const direction = elements["array-axis-view"].value;
canvasView.setViewDirection(direction);
announceSelection("3D 시점 · " + viewDirectionDescription(direction));
});
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 +1444,28 @@ 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-axis-view"].addEventListener("change", () => {
beamPatternSurfaceView.setViewDirection(elements["beam-pattern-axis-view"].value);
});
elements["beam-pattern-panel"].addEventListener("toggle", () => {
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
scheduleBeamPatternRender();
@@ -1314,13 +1674,19 @@ 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);
syncArrayViewDirection("iso");
syncBeamViewDirection("iso");
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
renderAll("기본 4×3 배열");
canvasView.fit();
document.documentElement.dataset.appReady = "true";
+42 -21
View File
@@ -1,6 +1,13 @@
export const MAX_MICROPHONES = 1024;
export const APERTURE_SHAPES = Object.freeze(["rectangle", "ellipse"]);
export const PRESET_TYPES = Object.freeze(["grid", "linear", "ring", "spiral", "random"]);
export const PRESET_TYPES = Object.freeze([
"grid",
"linear",
"ring",
"archimedean",
"spiral",
"random",
]);
const COORDINATE_SYSTEM = Object.freeze({
handedness: "right",
@@ -136,41 +143,51 @@ function linearPositions(options, bounds, shape) {
});
}
function rectanglePerimeterPosition(ratio, halfWidth, halfHeight) {
const width = halfWidth * 2;
const height = halfHeight * 2;
const perimeter = 2 * (width + height);
let distance = ratio * perimeter;
if (distance <= width) return [-halfWidth + distance, -halfHeight, 0];
distance -= width;
if (distance <= height) return [halfWidth, -halfHeight + distance, 0];
distance -= height;
if (distance <= width) return [halfWidth - distance, halfHeight, 0];
distance -= width;
return [-halfWidth, halfHeight - distance, 0];
function inscribedCircleRadius(bounds) {
return Math.min(bounds.halfWidth, bounds.halfHeight);
}
function ringPositions(options, bounds, shape) {
function ringPositions(options, bounds) {
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const radius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const ratio = index / count;
if (shape === "rectangle") {
return rectanglePerimeterPosition(ratio, bounds.halfWidth, bounds.halfHeight);
}
const angle = ratio * Math.PI * 2;
return [bounds.halfWidth * Math.cos(angle), bounds.halfHeight * Math.sin(angle), 0];
return [radius * Math.cos(angle), radius * Math.sin(angle), 0];
});
}
function spiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const maximumRadius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const radius = Math.sqrt((index + 0.5) / count);
const angle = index * GOLDEN_ANGLE;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
maximumRadius * radius * Math.cos(angle),
maximumRadius * radius * Math.sin(angle),
0,
];
});
}
function archimedeanSpiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const turns = finiteNumber(options.turns ?? 2.5, "나선 회전 수");
if (turns < 0.25 || turns > 32) {
throw new RangeError("나선 회전 수는 0.25 이상 32 이하이어야 합니다.");
}
const maximumRadius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const ratio = index / (count - 1);
const angle = ratio * turns * Math.PI * 2;
const radius = maximumRadius * ratio;
return [
radius * Math.cos(angle),
radius * Math.sin(angle),
0,
];
});
@@ -286,7 +303,10 @@ export function generatePresetMicrophones(options = {}) {
const bounds = presetBounds(options.aperture, options.edgeInsetM);
let positions;
if (options.type === "linear") positions = linearPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds);
else if (options.type === "archimedean") {
positions = archimedeanSpiralPositions(options, bounds);
}
else if (options.type === "spiral") positions = spiralPositions(options, bounds);
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
else positions = gridPositions(options, bounds, shape);
@@ -320,6 +340,7 @@ export function createArrayDefinition(options = {}) {
minimumSpacingM: options.minimumSpacingM,
edgeInsetM: options.edgeInsetM,
axis: options.axis,
turns: options.turns,
});
return arrayDefinition;
}
+36
View File
@@ -0,0 +1,36 @@
export const ORBIT_VIEW_DIRECTIONS = Object.freeze([
"iso",
"+x",
"-x",
"+y",
"-y",
"+z",
"-z",
]);
const ORBITS = Object.freeze({
iso: Object.freeze({
yaw: -Math.PI / 4,
pitch: Math.asin(1 / Math.sqrt(3)),
}),
"+x": Object.freeze({ yaw: -Math.PI / 2, pitch: 0 }),
"-x": Object.freeze({ yaw: Math.PI / 2, pitch: 0 }),
"+y": Object.freeze({ yaw: Math.PI, pitch: 0 }),
"-y": Object.freeze({ yaw: 0, pitch: 0 }),
"+z": Object.freeze({ yaw: 0, pitch: Math.PI / 2 }),
"-z": Object.freeze({ yaw: Math.PI, pitch: -Math.PI / 2 }),
});
/**
* Return the orthographic camera orbit for a view from the named axis toward
* the array origin. A fresh object prevents one view from mutating another.
*/
export function orbitForViewDirection(direction = "iso") {
const orbit = ORBITS[direction];
if (!orbit) {
throw new RangeError(
"view direction must be one of: " + ORBIT_VIEW_DIRECTIONS.join(", "),
);
}
return { ...orbit };
}
+278 -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%);
@@ -878,6 +1030,45 @@ details > summary::-webkit-details-marker {
border-right: 1px solid var(--line);
}
.axis-view-control {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(132px, 1fr);
align-items: center;
gap: 7px;
padding-right: 8px;
border-right: 1px solid var(--line);
}
.axis-view-control[hidden] {
display: none !important;
}
.axis-view-control > span {
color: var(--muted);
font-size: 0.64rem;
font-weight: 680;
white-space: nowrap;
}
.axis-view-control select {
width: 100%;
min-width: 0;
max-width: 184px;
min-height: 32px;
padding: 5px 27px 5px 8px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: #0b1714;
font-size: 0.66rem;
}
.axis-view-control select:focus-visible {
border-color: var(--mint);
outline: 2px solid rgb(101 242 191 / 10%);
}
.tool-button,
.icon-button {
min-height: 32px;
@@ -976,6 +1167,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 +1202,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 +1311,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 +1335,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) {
@@ -1226,6 +1468,11 @@ body[data-array-tool="add"] #array-canvas,
touch-action: pan-y;
}
#array-canvas[data-view-mode="3d"],
#beam-pattern-surface {
touch-action: pan-y;
}
.shortcut-help > div {
grid-template-columns: minmax(0, 1fr);
}
@@ -1285,6 +1532,17 @@ body[data-array-tool="add"] #array-canvas,
flex: 1 1 auto;
}
.axis-view-control {
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
padding-right: 0;
border-right: 0;
}
.axis-view-control select {
max-width: none;
}
.zoom-tools {
width: 100%;
}
@@ -1301,6 +1559,15 @@ body[data-array-tool="add"] #array-canvas,
height: 430px;
}
.canvas-front-badge {
bottom: 48px;
max-width: calc(100% - 24px);
border-radius: 10px;
line-height: 1.35;
text-overflow: clip;
white-space: normal;
}
.array-statistics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+92 -19
View File
@@ -1,21 +1,31 @@
# 마이크 어레이 수신 빔 패턴
Array Builder의 **수신 빔 패턴**은 현재 마이크 좌표에 conventional delay-and-sum을 적용했을 때의
narrowband far-field array factor를 360° polar 단면으로 보여줍니다. 마이크는 송신원이 아니므로
엄밀히는 방사 패턴이 아니라 **수신 응답**입니다.
narrowband far-field array factor를 보여줍니다. XZ·YZ·XY의 세 360° polar 단면을 동시에 비교하거나
구면 방향 전체를 표본화한 3D 패턴으로 검사할 수 있습니다. 마이크는 송신원이 아니므로 엄밀히는
방사 패턴이 아니라 **수신 응답**입니다.
## 가장 짧은 사용법
1. Array Builder에서 마이크 배치를 만들거나 Array JSON을 엽니다.
2. 배열 canvas 아래의 **수신 빔 패턴**을 펼칩니다.
3. 실제 음향파의 **아날로그 주파수**와 음속을 입력합니다.
4. XZ, YZ 또는 XY 360° 절단면과 조향각을 고릅니다.
5. 기본 **자동 · 위상 간격 기준**을 사용하고, 계산량 비교가 필요할 때만 고정 표본 수를 고릅니다.
6. polar 선, 조향 응답, 조건부 −3.0103 dB 폭과 sampled local maxima를 함께 읽습니다.
3. **로그 주파수 slider**를 움직여 실제 음향파의 아날로그 주파수를 live로 훑고 옆의 실제 값을
확인합니다.
4. 음속과 조향 방향을 정하고 XZ·YZ·XY 세 단면을 나란히 비교합니다.
5. 대각 방향을 포함한 전체 모양이 필요하면 3D 패턴으로 전환해 회전하며 검사합니다.
6. 기본 **자동 · 위상 간격 기준**을 사용하고, 계산량 비교가 필요할 때만 고정 표본 수를 고릅니다.
7. 각 보기의 raw 응답과 조건부 −3.0103 dB 폭, sampled local maxima 및 해상도 상태를 함께 읽습니다.
마이크를 드래그하는 동안에는 최대 720개 각도의 가벼운 preview를 표시합니다. 마우스나 pointer를
놓으면 선택한 표본 설정으로 다시 계산합니다. 빔 패턴 설정은 배열 좌표가 아니므로 undo history,
저장되지 않은 변경 상태와 Array JSON에는 들어가지 않습니다.
로그 slider에서 같은 화면 거리는 같은 Hz 차이가 아니라 같은 **주파수 비율**에 해당합니다. 따라서
넓은 범위를 저주파에 치우치지 않고 훑을 수 있습니다. 이 mapping은 UI 조작 방식일 뿐입니다. 계산에는
slider 위치가 아니라 현재의 선형 Hz 값 하나가 전달되며, 여러 주파수를 합치거나 보간해 하나의
패턴으로 만들지 않습니다. slider를 움직이면 그 순간의 한 주파수에서 현재 보기가 live로 갱신됩니다.
slider 위치 <code>p</code>는 0..6000의 정수이고
<code>f=10^(p/1000) Hz</code>로 바뀌므로 범위는 1 Hz..1 MHz, 한 칸의 비율은 약 1.002305입니다.
마이크를 드래그하는 동안에는 가벼운 preview를 표시하고, pointer를 놓으면 선택한 표본 설정으로 다시
계산합니다. 빔 패턴 설정과 2D/3D 보기 상태는 배열 좌표가 아니므로 undo history, 저장되지 않은 변경
상태와 Array JSON에는 들어가지 않습니다.
## 물리식과 좌표
@@ -25,11 +35,17 @@ narrowband far-field array factor를 360° polar 단면으로 보여줍니다.
<code>B(u) = (1/M) Σ_m exp[j (2πf/c) (u-u0)·(r_m-r_c)]</code>
각 마이크의 지수항은 조향 지연을 적용한 뒤 관찰 방향에 남는 복소 위상입니다. 관찰 방향과 조향
방향이 같아 <code>u=u0</code>이면 모든 지수항이 1이므로 <code>B(u0)=1</code>입니다.
<code>r_m-r_c</code>를 사용하므로 배열 전체를 평행 이동해도 패턴은 바뀌지 않고, 마이크 사이의 상대
배치만 영향을 줍니다.
<code>1/M</code>은 sampled maximum에 맞춘 사후 정규화가 아니라 고정 conventional
delay-and-sum weight입니다. 따라서 조향 방향은 이상적 모델에서 1이고, 같은 0 dB 응답이 여러
방향에 있으면 모두 그대로 남습니다. Array Builder는 <code>|B|²</code>와
<code>10 log10(|B|²)</code>를 계산하며, polar 표시 하한만 별도 clamp합니다. 표시 하한을 바꿔도
raw complex, power, peak 또는 HPBW 계산은 바뀌지 않습니다.
delay-and-sum weight입니다. 따라서 같은 0 dB 응답이 여러 방향에 있으면 모두 그대로 남습니다.
복소 응답의 실수부와 허수부를 <code>Re B</code>, <code>Im B</code>라 하면 raw power는
<code>P=|B|²=(Re B)²+(Im B)²</code>이고 raw dB는
<code>10 log10 P = 20 log10 |B|</code>입니다. 2D와 3D 보기의 표시 하한만 별도로 clamp하므로 표시
하한을 바꿔도 raw complex, power, peak 또는 HPBW 계산은 바뀌지 않습니다.
절단면 각도는 오른손 Array 좌표계에서 다음과 같습니다.
@@ -39,21 +55,45 @@ raw complex, power, peak 또는 HPBW 계산은 바뀌지 않습니다.
| YZ | +Z | +Y | XY 평면 배열의 broadside와 y축 단면 |
| XY | +X | +Y | 배열 평면 안의 edge 방향 단면 |
polar plot은 0°를 오른쪽, +90°를 위쪽에 그립니다. 각 plot은 구면 전체가 아니라 하나의
great-circle 단면입니다. 임의 3D 배열을 세 단면만으로 완전히 평가할 수는 없습니다.
세 polar plot 모두 0°를 오른쪽, +90°를 위쪽에 그립니다. 하나의 각도 값은 각 단면의 위 규약에
따라 해석되므로 0°가 XZ·YZ에서는 +Z이고 XY에서는 +X입니다. 각 polar plot은 구면 전체가 아니라
하나의 great-circle 단면입니다. 세 개를 동시에 보더라도 대각 방향의 lobe를 놓칠 수 있으므로 전체
방향 검사는 3D 패턴을 사용합니다.
배열 편집 canvas는 XY 좌표를 정확히 배치하기 위한 2D 보기이고, 3D 배열 보기는 x/y/z 배치와 앞뒤를
회전해 검사하는 보기입니다. 좌표를 바꾸려면 2D canvas나 숫자 필드를 사용합니다. **+Z FRONT** 표시는
배열 좌표계에서 양의 z 쪽을 전면으로 읽으라는 기준이며, 마이크 element가 실제로 단방향이라는 뜻은
아닙니다. 3D 패턴은 같은 좌표계의 단위구면 방향 <code>u</code>들을 평가하므로 polar 세 단면에 없는
대각 방향 응답도 드러냅니다.
배열 3D와 패턴 3D의 시점 선택은 “선택한 축 쪽에서 원점을 본다”는 규칙을 공유합니다. 예를 들어
**+Z FRONT에서 보기**는 카메라가 +Z에 있고 원점을 향하며, 화면 오른쪽은 +X, 위는 +Y입니다.
시점 선택과 자유 회전은 이미 계산된 좌표와 표면을 투영하는 카메라 동작일 뿐이므로 배열 좌표,
조향 방향 <code>u0</code>, 주파수와 raw 응답을 바꾸거나 빔 패턴을 다시 계산하지 않습니다.
3D 표면은 구면 격자의 각 표시용 <code>displayDb</code> 표본을
<code>rho=clamp((displayDb-dbFloor)/(0-dbFloor),0,1)</code> 반지름으로 놓고 인접 격자점을 면으로
연결합니다. 이는 dB 동적 범위를 읽기 위한 표시 좌표이며 raw power를 바꾸지 않습니다. 격자 사이의
새 물리 응답을 보간하거나 surface를 평활화하지 않습니다.
## 모델에 들어가는 것과 빠지는 것
현재 array factor는 다음 조건을 명시적으로 고정합니다.
- analog monochromatic narrowband 가설
- 자유장의 far-field plane wave
- 한 번에 아날로그 주파수 하나만 보는 monochromatic narrowband 가설
- 거리와 곡률이 없는 자유장의 far-field plane wave
- 위치만 가진, 이상적으로 보정된 무지향성 point microphone
- uniform conventional delay-and-sum weight
- meter 좌표와 사용자가 입력한 음속
slider를 움직여 여러 주파수의 모양을 연속해서 볼 수는 있지만, 각 frame은 서로 독립적인 단일
주파수 계산입니다. 광대역 신호의 spectrum을 적분하거나 서로 다른 주파수의 map을 융합하지 않습니다.
far-field 가정에는 source 거리 입력이 없으므로 거리 추정, 구면파 곡률, <code>1/r</code> 감쇠도 이
plot에서 평가할 수 없습니다.
따라서 방 반사·회절·산란, near-field 거리와 구면 감쇠, baffle·housing·상호결합, 마이크의 각도별
element directivity는 포함하지 않습니다. Array JSON의 <code>sensitivityDb</code>,
element directivity는 포함하지 않습니다. 이 패턴은 위치 확률분포나 실제 장면의 localization
정확도도 아닙니다. Array JSON의 <code>sensitivityDb</code>,
<code>phaseOffsetDeg</code>와 <code>frequencyResponse</code>도 이 geometry-only plot에는
섞지 않습니다. 이 보정들은 합성 및 위치추정 pipeline에서 별도로 적용됩니다. 주파수 응답은
주파수별 channel transfer이고, 그 자체가 각도별 element pattern은 아닙니다.
@@ -93,12 +133,21 @@ evaluator로 각 경계를 48회 이분합니다.
평가 상한을 지킵니다. 상한에서도 기준을 만족하지 못하면 계산을 예쁘게 보이도록 평활화하거나
peak를 제거하지 않고 미확정 상태를 표시합니다.
portable 함수의 호출별 상한과 별도로 Array Builder는 UI 정지를 줄이기 위해 live preview 전체를
240만 microphone-direction 평가로, pointer를 놓은 뒤의 전체 해상도 계산을 1,600만 평가로 제한합니다.
XZ·YZ·XY 동시 보기에서는 이 예산을 세 호출에 동일하게 분배합니다. 필요한 자동 표본이 예산을
넘으면 coarse 결과로 바꾸지 않고 계산을 거부하므로, 사용자가 고정 표본 수·주파수·채널 수의
trade-off를 직접 선택할 수 있습니다.
## portable API
<code>beam-pattern.js</code>는 DOM, Scene, ADC와 외부 package에 의존하지 않는 ES module입니다.
~~~js
import { computeReceiveBeamPattern } from "./packages/acoustics-core/beam-pattern.js";
import {
computeReceiveBeamPattern,
computeReceiveBeamPatternSphere,
} from "./packages/acoustics-core/beam-pattern.js";
const pattern = computeReceiveBeamPattern({
array,
@@ -115,8 +164,32 @@ drawPolar(pattern.anglesDeg, pattern.displayDb);
if (!pattern.angularSampling.metricResolutionSatisfied) {
showUnresolved(pattern.angularSampling.maximumAdjacentPhaseStepRadBound);
}
const sphere = computeReceiveBeamPatternSphere({
array,
frequencyHz: 49_125,
speedOfSound: 343,
steeringAzimuthDeg: 0,
steeringElevationDeg: 90,
azimuthSampleCount: 120,
elevationSampleCount: 61,
dbFloor: -60,
});
// elevation-major: index = elevationIndex * azimuthSampleCount + azimuthIndex
drawSphere(
sphere.azimuthAxisDeg,
sphere.elevationAxisDeg,
sphere.displayDb,
sphere.grid.shape,
);
~~~
portable 함수는 명시한 절단면 하나를 계산합니다. Array Builder의 동시 polar 보기는 같은 주파수와
공통 설정으로 XZ·YZ·XY를 각각 계산해 나란히 표시하는 UI 구성입니다. 3D 보기 역시 같은 물리식의
관찰 방향 <code>u</code>를 구면 위에서 평가합니다. 세 단면을 평균하거나 maximum으로 합치는 새
beamforming 알고리즘이 아닙니다.
결과는 normalized raw <code>responseReal</code>, <code>responseImaginary</code>,
<code>responsePower</code>, <code>responseLinear</code>, unclamped <code>responseDb</code>와
표시용 <code>displayDb</code>를 각각 보존합니다. workload에는 baseline pair 비교, sampled phasor,
+3 -2
View File
@@ -100,9 +100,10 @@
<h2>내 마이크 배열 만들기</h2>
<ol class="guide-steps">
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
<li>격자·선형·원형·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
<li>격자·선형·원형 링·Archimedean 나선·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
<li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.</li>
<li>3D 검사에서 등각 또는 ±X/±Y/±Z 축에서 원점을 보는 시점으로 배치와 +Z FRONT를 확인합니다.</li>
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인하고, 3D 패턴도 같은 축 시점으로 정렬합니다.</li>
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
</ol>
+407
View File
@@ -9,6 +9,11 @@ export const MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES = 1_024;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ = 1_000_000;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS = 600_000;
export const RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD = Math.PI / 8;
export const DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES = 360;
export const DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES = 181;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES = 1_440;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES = 721;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES = 262_144;
const HALF_POWER = 0.5;
const RADIANS_PER_DEGREE = Math.PI / 180;
@@ -82,6 +87,29 @@ export function receiveBeamPatternDirection(cut, angleDeg) {
return [0, sine, cosine];
}
/**
* Convert standard spherical azimuth/elevation to an array-to-source unit
* vector. Azimuth is measured in the XY plane from +X toward +Y. Elevation is
* measured from the XY plane toward +Z, with +90 degrees at the north pole.
*/
export function receiveBeamPatternSphereDirection(azimuthDeg, elevationDeg) {
const azimuthRadians = normalizeBeamPatternAngle(azimuthDeg) *
RADIANS_PER_DEGREE;
const elevation = requireFinite(elevationDeg, "elevationDeg");
if (elevation < -90 || elevation > 90) {
throw new RangeError("elevationDeg must lie inside -90..90 degrees.");
}
if (elevation === 90) return [0, 0, 1];
if (elevation === -90) return [0, 0, -1];
const elevationRadians = elevation * RADIANS_PER_DEGREE;
const cosineElevation = Math.cos(elevationRadians);
return [
cosineElevation * Math.cos(azimuthRadians),
cosineElevation * Math.sin(azimuthRadians),
Math.sin(elevationRadians),
];
}
function cutCoordinates(position, cut) {
if (cut === "xy") return [position[0], position[1]];
if (cut === "xz") return [position[2], position[0]];
@@ -289,6 +317,32 @@ function maximumProjectedBaseline(coordinates) {
return maximum;
}
function maximumSpatialBaseline(positions) {
let maximum = 0;
for (let left = 0; left < positions.length; left += 1) {
for (let right = left + 1; right < positions.length; right += 1) {
maximum = Math.max(
maximum,
Math.hypot(
positions[left][0] - positions[right][0],
positions[left][1] - positions[right][1],
positions[left][2] - positions[right][2],
),
);
}
}
return maximum;
}
function requireSphereSampleCount(value, label, minimum, maximum) {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
label + " must be an integer inside " + minimum + ".." + maximum + ".",
);
}
return value;
}
function recommendedSampleCount(waveNumber, projectedApertureM) {
if (projectedApertureM === 0) return 36;
const ratio = RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD /
@@ -623,3 +677,356 @@ export function computeReceiveBeamPattern(options) {
},
};
}
/**
* Sample the same ideal conventional receive array factor over a full sphere.
*
* The returned rectangular grid is equirectangular: azimuth samples span
* [0, 360) without a duplicate endpoint, while elevation samples include both
* poles. Pole rows therefore contain coincident directions for every azimuth.
* All response arrays are raw elevation-major samples; no interpolation,
* smoothing, frequency folding, or sampled-maximum normalization is applied.
*/
export function computeReceiveBeamPatternSphere(options) {
const array = options?.array;
const positions = requireMicrophones(array);
const frequencyHz = requirePositive(options?.frequencyHz, "frequencyHz");
if (frequencyHz > MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ) {
throw new RangeError(
"frequencyHz must not exceed " +
MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ + " Hz.",
);
}
const speedOfSound = requirePositive(options?.speedOfSound ?? 343, "speedOfSound");
const steeringAzimuthDeg = normalizeBeamPatternAngle(
options?.steeringAzimuthDeg ?? 0,
);
const steeringElevationDeg = requireFinite(
options?.steeringElevationDeg ?? 90,
"steeringElevationDeg",
);
if (steeringElevationDeg < -90 || steeringElevationDeg > 90) {
throw new RangeError(
"steeringElevationDeg must lie inside -90..90 degrees.",
);
}
const azimuthSampleCount = requireSphereSampleCount(
options?.azimuthSampleCount ??
DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES,
"azimuthSampleCount",
4,
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES,
);
const elevationSampleCount = requireSphereSampleCount(
options?.elevationSampleCount ??
DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES,
"elevationSampleCount",
3,
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES,
);
const sampleCount = azimuthSampleCount * elevationSampleCount;
if (
!Number.isSafeInteger(sampleCount) ||
sampleCount > MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES
) {
throw new RangeError(
"sphere sample product exceeds the receive beam-pattern limit of " +
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES + ".",
);
}
const dbFloor = requireFinite(
options?.dbFloor ?? DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR,
"dbFloor",
);
if (dbFloor < -300 || dbFloor > -1) {
throw new RangeError("dbFloor must lie inside -300..-1 dB.");
}
const maximumEvaluations = options?.maximumEvaluations ??
MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS;
if (!Number.isSafeInteger(maximumEvaluations) || maximumEvaluations < 1) {
throw new RangeError("maximumEvaluations must be a positive safe integer.");
}
const microphoneCount = positions.length;
const sampleEvaluationCount = microphoneCount * sampleCount;
if (
!Number.isSafeInteger(sampleEvaluationCount) ||
sampleEvaluationCount > maximumEvaluations
) {
throw new RangeError(
"receive beam-pattern sphere requires " + sampleEvaluationCount +
" microphone-direction evaluations; limit is " +
maximumEvaluations + ".",
);
}
const maximumBaselineComparisons = options?.maximumBaselineComparisons ??
MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS;
if (
!Number.isSafeInteger(maximumBaselineComparisons) ||
maximumBaselineComparisons < 0
) {
throw new RangeError(
"maximumBaselineComparisons must be a non-negative safe integer.",
);
}
const spatialBaselineComparisons =
microphoneCount * (microphoneCount - 1) / 2;
if (spatialBaselineComparisons > maximumBaselineComparisons) {
throw new RangeError(
"receive beam-pattern sphere requires " + spatialBaselineComparisons +
" spatial-baseline comparisons; limit is " +
maximumBaselineComparisons + ".",
);
}
const centroid = [0, 0, 0];
for (const position of positions) {
centroid[0] += position[0] / microphoneCount;
centroid[1] += position[1] / microphoneCount;
centroid[2] += position[2] / microphoneCount;
}
const centeredPositions = positions.map((position) => [
position[0] - centroid[0],
position[1] - centroid[1],
position[2] - centroid[2],
]);
const spatialApertureM = maximumSpatialBaseline(centeredPositions);
const waveNumber = TAU * frequencyHz / speedOfSound;
const maximumPhaseMagnitudeRadBound = 2 * waveNumber * spatialApertureM;
if (
!Number.isFinite(waveNumber) ||
!Number.isFinite(maximumPhaseMagnitudeRadBound)
) {
throw new RangeError(
"frequency, speed of sound, and array coordinates overflow phase.",
);
}
const azimuthStepDeg = 360 / azimuthSampleCount;
const elevationStepDeg = 180 / (elevationSampleCount - 1);
const azimuthStepRad = TAU / azimuthSampleCount;
const elevationStepRad = Math.PI / (elevationSampleCount - 1);
const azimuthAdjacentDirectionChordBound =
2 * Math.sin(azimuthStepRad / 2);
const elevationAdjacentDirectionChordBound =
2 * Math.sin(elevationStepRad / 2);
const maximumCellDiagonalDirectionChordBound = 2 * Math.hypot(
Math.sin(elevationStepRad / 2),
Math.cos(elevationStepRad / 2) * Math.sin(azimuthStepRad / 2),
);
const maximumAzimuthAdjacentPhaseStepRadBound =
waveNumber * spatialApertureM * azimuthAdjacentDirectionChordBound;
const maximumElevationAdjacentPhaseStepRadBound =
waveNumber * spatialApertureM * elevationAdjacentDirectionChordBound;
const maximumCellDiagonalPhaseStepRadBound =
waveNumber * spatialApertureM *
maximumCellDiagonalDirectionChordBound;
const resolutionCriterionSatisfied =
maximumCellDiagonalPhaseStepRadBound <=
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD *
(1 + 64 * Number.EPSILON);
const azimuthAxisDeg = new Float64Array(azimuthSampleCount);
const elevationAxisDeg = new Float64Array(elevationSampleCount);
const azimuthCosines = new Float64Array(azimuthSampleCount);
const azimuthSines = new Float64Array(azimuthSampleCount);
const elevationCosines = new Float64Array(elevationSampleCount);
const elevationSines = new Float64Array(elevationSampleCount);
for (let azimuthIndex = 0; azimuthIndex < azimuthSampleCount; azimuthIndex += 1) {
const azimuthDeg = azimuthIndex * azimuthStepDeg;
const azimuthRadians = azimuthIndex * azimuthStepRad;
azimuthAxisDeg[azimuthIndex] = azimuthDeg;
azimuthCosines[azimuthIndex] = Math.cos(azimuthRadians);
azimuthSines[azimuthIndex] = Math.sin(azimuthRadians);
}
for (
let elevationIndex = 0;
elevationIndex < elevationSampleCount;
elevationIndex += 1
) {
const elevationDeg = elevationIndex === elevationSampleCount - 1
? 90
: -90 + elevationIndex * elevationStepDeg;
const elevationRadians = elevationDeg * RADIANS_PER_DEGREE;
elevationAxisDeg[elevationIndex] = elevationDeg;
if (elevationIndex === 0 || elevationIndex === elevationSampleCount - 1) {
elevationCosines[elevationIndex] = 0;
elevationSines[elevationIndex] = elevationIndex === 0 ? -1 : 1;
} else {
elevationCosines[elevationIndex] = Math.cos(elevationRadians);
elevationSines[elevationIndex] = Math.sin(elevationRadians);
}
}
const steeringDirection = receiveBeamPatternSphereDirection(
steeringAzimuthDeg,
steeringElevationDeg,
);
const steeringProjections = new Float64Array(microphoneCount);
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const position = centeredPositions[microphoneIndex];
steeringProjections[microphoneIndex] =
position[0] * steeringDirection[0] +
position[1] * steeringDirection[1] +
position[2] * steeringDirection[2];
}
const responseReal = new Float64Array(sampleCount);
const responseImaginary = new Float64Array(sampleCount);
const responsePower = new Float64Array(sampleCount);
const responseLinear = new Float64Array(sampleCount);
const responseDb = new Float64Array(sampleCount);
const displayDb = new Float64Array(sampleCount);
let sampledMaximumLinear = 0;
let sampledMaximumPower = 0;
for (
let elevationIndex = 0;
elevationIndex < elevationSampleCount;
elevationIndex += 1
) {
const cosineElevation = elevationCosines[elevationIndex];
const directionZ = elevationSines[elevationIndex];
for (
let azimuthIndex = 0;
azimuthIndex < azimuthSampleCount;
azimuthIndex += 1
) {
const directionX = cosineElevation * azimuthCosines[azimuthIndex];
const directionY = cosineElevation * azimuthSines[azimuthIndex];
let summedReal = 0;
let summedImaginary = 0;
for (
let microphoneIndex = 0;
microphoneIndex < microphoneCount;
microphoneIndex += 1
) {
const position = centeredPositions[microphoneIndex];
const arrivalProjection =
position[0] * directionX +
position[1] * directionY +
position[2] * directionZ;
const phase = waveNumber *
(arrivalProjection - steeringProjections[microphoneIndex]);
summedReal += Math.cos(phase);
summedImaginary += Math.sin(phase);
}
const flatIndex =
elevationIndex * azimuthSampleCount + azimuthIndex;
const real = summedReal / microphoneCount;
const imaginary = summedImaginary / microphoneCount;
const power = real * real + imaginary * imaginary;
if (
!Number.isFinite(real) ||
!Number.isFinite(imaginary) ||
!Number.isFinite(power)
) {
throw new RangeError(
"receive beam-pattern sphere phasor accumulation became non-finite.",
);
}
const magnitude = Math.sqrt(power);
const decibels = power > 0 ? 10 * Math.log10(power) : -Infinity;
responseReal[flatIndex] = real;
responseImaginary[flatIndex] = imaginary;
responsePower[flatIndex] = power;
responseLinear[flatIndex] = magnitude;
responseDb[flatIndex] = decibels;
displayDb[flatIndex] = Math.max(dbFloor, decibels);
sampledMaximumLinear = Math.max(sampledMaximumLinear, magnitude);
sampledMaximumPower = Math.max(sampledMaximumPower, power);
}
}
const returnedFloat64Count =
sampleCount * 6 + azimuthSampleCount + elevationSampleCount;
return {
model: "far-field-narrowband-conventional-delay-and-sum-receive-array-factor",
surface: "full-sphere-equirectangular-samples",
assumptions: Object.freeze({
wavefront: "far-field-plane-wave",
bandwidth: "narrowband",
elements: "ideal-calibrated-omnidirectional-point-microphones",
environment: "free-field",
coordinates: "meters",
}),
coordinateSystem: Object.freeze({
handedness: "right-handed",
azimuth: "XY-plane-from-+X-toward-+Y",
elevation: "from-XY-plane-toward-+Z",
directionEquation:
"[cos(elevation)*cos(azimuth), cos(elevation)*sin(azimuth), sin(elevation)]",
}),
frequencyHz,
speedOfSound,
wavelengthM: speedOfSound / frequencyHz,
waveNumberRadPerM: waveNumber,
steeringAzimuthDeg,
steeringElevationDeg,
steeringDirection,
microphoneCount,
azimuthSampleCount,
elevationSampleCount,
sampleCount,
gridShape: [elevationSampleCount, azimuthSampleCount],
flatIndexing: "elevation-major-row-major",
evaluationCount: sampleEvaluationCount,
dbFloor,
grid: {
topology: "equirectangular-azimuth-elevation",
shape: [elevationSampleCount, azimuthSampleCount],
flatIndex: "elevationIndex * azimuthSampleCount + azimuthIndex",
azimuthEndpointDuplicated: false,
elevationEndpointsIncluded: true,
poleAzimuthSamplesCoincident: true,
uniqueDirectionCount:
azimuthSampleCount * (elevationSampleCount - 2) + 2,
interpolation: "none",
smoothing: "none",
sampledMaximumNormalization: false,
},
workload: {
spatialBaselineComparisons,
maximumBaselineComparisons,
sampleEvaluationCount,
maximumEvaluationCount: sampleEvaluationCount,
maximumEvaluations,
returnedFloat64Count,
returnedFloat64Bytes:
returnedFloat64Count * Float64Array.BYTES_PER_ELEMENT,
maximumSurfaceSamples: MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES,
},
angularSampling: {
mode: "fixed-equirectangular-grid",
criterion: "maximum-cell-diagonal-pair-phase-step",
azimuthStepDeg,
elevationStepDeg,
spatialApertureM,
maximumPhaseMagnitudeRadBound,
azimuthAdjacentDirectionChordBound,
elevationAdjacentDirectionChordBound,
maximumCellDiagonalDirectionChordBound,
maximumAzimuthAdjacentPhaseStepRadBound,
maximumElevationAdjacentPhaseStepRadBound,
maximumCellDiagonalPhaseStepRadBound,
phaseStepLimitRad:
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD,
resolutionCriterionSatisfied,
interpretation:
"sampling-bound-only-not-proof-of-complete-lobe-detection",
},
azimuthAxisDeg,
elevationAxisDeg,
responseReal,
responseImaginary,
responsePower,
responseLinear,
responseDb,
displayDb,
sampledSummary: {
maximumLinearOnGrid: sampledMaximumLinear,
maximumPowerOnGrid: sampledMaximumPower,
maximumDbOnGrid: sampledMaximumPower > 0
? 10 * Math.log10(sampledMaximumPower)
: -Infinity,
},
};
}
+544 -38
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");
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
"polar plot has four inner dB rings and the 0 dB ring",
);
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
const beamCuts = ["xz", "yz", "xy"];
const beamPlots = Object.fromEntries(beamCuts.map((cut) => [
cut,
documentObject.querySelector("#beam-pattern-plot-" + cut),
]));
const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [
cut,
beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"),
]));
for (const cut of beamCuts) {
const beamPlot = beamPlots[cut];
assert(
initialBeamPaths[cut]?.startsWith("M"),
cut.toUpperCase() + " polar response path is rendered",
);
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
cut.toUpperCase() + " polar plot has four inner dB rings and the 0 dB ring",
);
assertEqual(beamPlot.dataset.cut, cut, cut.toUpperCase() + " cut provenance");
assertEqual(
beamPlot.dataset.frequencyHz,
"1000",
cut.toUpperCase() + " default analog frequency",
);
assert(
Number(beamPlot.dataset.sampleCount) >= 720,
cut.toUpperCase() + " auto angular sampling uses at least 720 directions",
);
}
assert(
Number(beamPlot.dataset.sampleCount) >= 720,
"auto angular sampling uses at least 720 directions",
!documentObject.querySelector("#beam-pattern-cuts-view").hidden &&
documentObject.querySelector("#beam-pattern-surface-view").hidden,
"three polar cuts are the simultaneous default view",
);
assertEqual(
documentObject.querySelectorAll("#beam-pattern-cuts-view .beam-pattern-figure").length,
3,
"XZ, YZ, and XY figures are present together",
);
assert(
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
@@ -125,16 +155,34 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
const initialSaveState = documentObject.querySelector("#save-state").textContent;
const frequencySlider = documentObject.querySelector("#beam-pattern-frequency");
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
49_125,
frequencySlider,
logarithmicFrequencyPosition(49_125),
"input",
);
await waitFor(
() => beamPlot.dataset.frequencyHz === "49125" &&
Number(beamPlot.dataset.sampleCount) > 720,
"above-Nyquist-style analog pattern with auto angular sampling",
() => documentObject.documentElement.dataset.beamPatternPreview === "true" &&
beamCuts.every((cut) =>
Number(beamPlots[cut].dataset.frequencyHz) > 48_000 &&
beamPlots[cut].dataset.sampleCount === "720"),
"logarithmic slider input renders a bounded live preview in every cut",
);
const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow"));
assert(
previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000,
"log slider position maps to the intended above-Nyquist-style analog frequency",
);
assert(
documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"),
"log slider exposes its physical frequency value",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
await waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720),
"logarithmic slider change renders full auto resolution in every cut",
);
assert(
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
@@ -146,15 +194,15 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
360,
);
await waitFor(
() => beamPlot.dataset.sampleCount === "360" &&
() => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족",
),
"coarse angular grid exposes unresolved metrics",
"coarse angular grid exposes unresolved metrics for the simultaneous cuts",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
documentObject.querySelector("#beam-pattern-speed-of-sound"),
"",
"input",
);
@@ -168,18 +216,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
documentObject.querySelector("#beam-pattern-speed-of-sound"),
343,
"input",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
1_000,
frequencySlider,
logarithmicFrequencyPosition(1_000),
"input",
);
frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-samples"),
"auto",
);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
beamPlot.dataset.frequencyHz === "1000",
documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamCuts.every((cut) =>
beamPlots[cut].dataset.frequencyHz === "1000" &&
Number(beamPlots[cut].dataset.sampleCount) >= 720),
"beam preview recovery",
);
assertEqual(
@@ -187,6 +245,152 @@ 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 beamAxisView = documentObject.querySelector("#beam-pattern-axis-view");
assertEqual(beamAxisView.value, "iso", "3D beam surface starts in isometric view");
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam canvas exposes isometric provenance");
assert(
beamSurface.getAttribute("aria-label").includes("등각 원점 보기"),
"beam canvas accessible name exposes its current view",
);
const beamComputeBeforeAxisView =
documentObject.documentElement.dataset.beamPatternComputeMs;
const beamFrequencyBeforeAxisView = beamSurface.dataset.frequencyHz;
const surfaceBeforeAxisView = beamSurface.toDataURL();
beamAxisView.focus();
dispatchValue(windowObject, beamAxisView, "+z");
await nextPaint(windowObject);
assertEqual(beamSurface.dataset.viewAxis, "+z", "beam surface aligns from +Z");
assert(
beamSurface.toDataURL() !== surfaceBeforeAxisView,
"beam surface bitmap changes for the exact +Z camera",
);
dispatchValue(windowObject, beamAxisView, "+x");
await nextPaint(windowObject);
assertEqual(beamSurface.dataset.viewAxis, "+x", "beam surface aligns from +X");
assertEqual(
documentObject.activeElement,
beamAxisView,
"beam axis selector keeps keyboard focus for consecutive comparisons",
);
assertEqual(
documentObject.documentElement.dataset.beamPatternComputeMs,
beamComputeBeforeAxisView,
"beam camera changes do not recompute the physical response",
);
assertEqual(
beamSurface.dataset.frequencyHz,
beamFrequencyBeforeAxisView,
"beam camera changes preserve the evaluated analog frequency",
);
assertEqual(
documentObject.querySelector("#save-state").textContent,
initialSaveState,
"beam camera changes stay outside Array dirty state",
);
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",
);
assertEqual(beamSurface.dataset.viewAxis, "custom", "beam drag enters free-orbit mode");
assertEqual(beamAxisView.value, "custom", "beam view selector reflects free orbit");
assert(
beamSurface.getAttribute("aria-label").includes("자유 회전"),
"beam canvas accessible name follows free orbit",
);
beamSurface.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "r",
}));
await nextPaint(windowObject);
assertEqual(beamSurface.dataset.viewAxis, "iso", "beam R shortcut restores isometric view");
assertEqual(beamAxisView.value, "iso", "beam selector follows the R shortcut");
assert(
beamSurface.getAttribute("aria-label").includes("등각 원점 보기"),
"beam accessible name follows the R shortcut",
);
assertEqual(
documentObject.documentElement.dataset.beamPatternComputeMs,
beamComputeBeforeAxisView,
"beam camera reset also avoids a physical-response recompute",
);
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 +407,185 @@ 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(
"시점 등각",
),
"3D canvas identifies its isometric camera",
);
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 arrayAxisControl = documentObject.querySelector("#array-axis-view-control");
const arrayAxisView = documentObject.querySelector("#array-axis-view");
assert(!arrayAxisControl.hidden, "array axis selector appears only for 3D inspection");
assertEqual(arrayAxisView.value, "iso", "array 3D starts in isometric view");
assert(
canvas.getAttribute("aria-label").includes("등각 원점 보기"),
"array canvas accessible name exposes its current view",
);
const arraySaveStateBeforeAxisView =
documentObject.querySelector("#save-state").textContent;
const array3dBeforeAxisView = canvas.toDataURL();
arrayAxisView.focus();
dispatchValue(windowObject, arrayAxisView, "+z");
await nextPaint(windowObject);
assertEqual(canvas.dataset.viewAxis, "+z", "array canvas aligns from +Z FRONT");
dispatchValue(windowObject, arrayAxisView, "+x");
await nextPaint(windowObject);
assertEqual(canvas.dataset.viewAxis, "+x", "array canvas aligns from +X");
assertEqual(
documentObject.activeElement,
arrayAxisView,
"array axis selector keeps keyboard focus for consecutive comparisons",
);
assert(
canvas.toDataURL() !== array3dBeforeAxisView,
"array 3D bitmap changes after axis alignment",
);
assertEqual(
selectedCoordinates(documentObject).join(","),
coordinatesBeforeArray3d.join(","),
"axis camera changes leave microphone coordinates unchanged",
);
assertEqual(
documentObject.querySelector("#save-state").textContent,
arraySaveStateBeforeAxisView,
"axis camera changes do not dirty the Array document",
);
const selectionBeforeArrayOrbit = documentObject
.querySelector('#microphone-list [aria-selected="true"]')?.dataset.microphoneId;
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",
);
assertEqual(canvas.dataset.viewAxis, "custom", "array drag enters free-orbit mode");
assertEqual(arrayAxisView.value, "custom", "array view selector reflects free orbit");
assert(
canvas.getAttribute("aria-label").includes("자유 회전"),
"array canvas accessible name follows free orbit",
);
assertEqual(
documentObject.querySelector('#microphone-list [aria-selected="true"]')
?.dataset.microphoneId,
selectionBeforeArrayOrbit,
"array orbit drag preserves the current channel selection",
);
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "r",
}));
await nextPaint(windowObject);
assertEqual(canvas.dataset.viewAxis, "iso", "array R shortcut restores isometric view");
assertEqual(arrayAxisView.value, "iso", "array selector follows the R shortcut");
assert(
canvas.getAttribute("aria-label").includes("등각 원점 보기"),
"array accessible name follows the R shortcut",
);
assertEqual(
documentObject.querySelector("#save-state").textContent,
arraySaveStateBeforeAxisView,
"array camera reset stays outside Array dirty state",
);
documentObject.querySelector("#array-view-2d-button").click();
await waitFor(
() => canvas.dataset.viewMode === "2d" &&
canvas.getAttribute("aria-label").includes("평면 편집"),
"return to array 2D editing",
);
assert(arrayAxisControl.hidden, "array axis selector hides in 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 +617,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(
@@ -322,6 +706,53 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.documentElement.dataset.arrayValid === "true",
"ellipse ring validation",
);
assert(
documentObject.querySelector("#preset-hint").textContent.includes("실제 내접원"),
"ring hint describes a true circle instead of the boundary perimeter",
);
for (let index = 0; index < microphoneCount(documentObject); index += 1) {
microphoneButtons(documentObject)[index].click();
const [x, y] = selectedCoordinates(documentObject);
assertNear(Math.hypot(x, y), 0.215, 1e-8, "visible ring radius " + index);
}
dispatchValue(
windowObject,
documentObject.querySelector("#preset-type"),
"archimedean",
);
assert(
!documentObject.querySelector("#preset-turns").closest(".field").hidden,
"Archimedean preset exposes its turn count",
);
assert(
documentObject.querySelector("#preset-hint").textContent.includes("r∝θ"),
"Archimedean hint identifies the curved spiral law",
);
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 9);
dispatchValue(windowObject, documentObject.querySelector("#preset-turns"), 1.5);
documentObject.querySelector("#generate-preset-button").click();
await waitFor(() => microphoneCount(documentObject) === 9, "Archimedean generation");
microphoneButtons(documentObject)[0].click();
assertEqual(
selectedCoordinates(documentObject).join(","),
"0,0,0",
"Archimedean spiral visibly starts at the center",
);
microphoneButtons(documentObject).at(-1).click();
const archimedeanEnd = selectedCoordinates(documentObject);
assertNear(archimedeanEnd[0], -0.215, 1e-8, "1.5-turn spiral ends at -x");
assertNear(archimedeanEnd[1], 0, 1e-8, "1.5-turn spiral endpoint y");
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "spiral");
assert(
documentObject.querySelector("#preset-turns").closest(".field").hidden,
"Fermat preset does not claim an Archimedean turn count",
);
assert(
documentObject.querySelector("#preset-hint").textContent.includes("해바라기형"),
"Fermat hint explains why its points do not look like one connected curve",
);
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "random");
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
@@ -335,7 +766,7 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
documentObject.querySelector("#undo-button").click();
assertEqual(microphoneCount(documentObject), 8, "preset replacement is undoable");
assertEqual(microphoneCount(documentObject), 9, "preset replacement is undoable");
documentObject.querySelector("#redo-button").click();
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
@@ -789,32 +1220,107 @@ 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",
);
assertEqual(
windowObject.getComputedStyle(beamSurface).touchAction,
"pan-y",
"compact beam 3D view preserves vertical page scrolling",
);
const compactBeamAxisRect = documentObject.querySelector("#beam-pattern-axis-view")
.getBoundingClientRect();
assert(
compactBeamAxisRect.left >= compactBeamSurfaceCardRect.left - 1 &&
compactBeamAxisRect.right <= compactBeamSurfaceCardRect.right + 1,
"compact beam axis selector stays inside its analysis card",
);
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",
);
documentObject.querySelector("#array-view-3d-button").click();
await waitFor(() => canvas.dataset.viewMode === "3d", "compact array 3D mode");
const compactAxisControlRect = arrayAxisControl.getBoundingClientRect();
const compactToolbarRect = documentObject.querySelector(".workspace-toolbar")
.getBoundingClientRect();
assert(
compactAxisControlRect.left >= compactToolbarRect.left - 1 &&
compactAxisControlRect.right <= compactToolbarRect.right + 1,
"compact array axis selector stays inside its toolbar",
);
const compactFrontBadge = documentObject.querySelector("#canvas-front-badge");
assert(
compactFrontBadge.scrollWidth <= compactFrontBadge.clientWidth + 1 &&
windowObject.getComputedStyle(compactFrontBadge).whiteSpace !== "nowrap",
"compact 3D direction badge wraps without clipping axis and FRONT cues",
);
assertEqual(
windowObject.getComputedStyle(canvas).touchAction,
"pan-y",
"compact array 3D view preserves vertical page scrolling",
);
assert(
documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1,
"array axis selector creates no horizontal overflow at 390px",
);
documentObject.querySelector("#array-view-2d-button").click();
await waitFor(() => canvas.dataset.viewMode === "2d", "return from compact array 3D");
assertEqual(
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
"pan-y",
+150
View File
@@ -7,12 +7,28 @@ import {
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
projectArrayPosition3d,
} from "/apps/array-builder/canvas-view.js";
import {
ORBIT_VIEW_DIRECTIONS,
orbitForViewDirection,
} from "/apps/array-builder/orbit-view.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
}
function projectionViewport(orbit) {
return {
...orbit,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
}
function fixture() {
return {
aperture: { width: 0.4, height: 0.2 },
@@ -139,3 +155,137 @@ 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 yaw=0, pitch=0 projection은 -Y축 위치에서 원점을 본다", () => {
const negativeYAxisView = {
yaw: 0,
pitch: 0,
centerPixelX: 200,
centerPixelY: 120,
centerHorizontal: 0,
centerVertical: 0,
scale: 100,
};
const positiveX = projectArrayPosition3d([1, 0, 0], negativeYAxisView);
const positiveZ = projectArrayPosition3d([0, 0, 1], negativeYAxisView);
const positiveY = projectArrayPosition3d([0, 1, 0], negativeYAxisView);
const negativeY = projectArrayPosition3d([0, -1, 0], negativeYAxisView);
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 projects upward");
assertNear(negativeY.depth, 1, 1e-12, "-Y is on the viewer side");
assertNear(positiveY.depth, -1, 1e-12, "+Y is behind the origin");
});
test("Array canvas의 여섯 axis preset은 해당 축 위치에서 원점을 본다", () => {
assertEqual(
ORBIT_VIEW_DIRECTIONS.join(","),
"iso,+x,-x,+y,-y,+z,-z",
"public preset order",
);
const axisVectors = {
"+x": [1, 0, 0],
"-x": [-1, 0, 0],
"+y": [0, 1, 0],
"-y": [0, -1, 0],
"+z": [0, 0, 1],
"-z": [0, 0, -1],
};
for (const direction of ORBIT_VIEW_DIRECTIONS.slice(1)) {
const viewport = projectionViewport(orbitForViewDirection(direction));
const cameraSide = projectArrayPosition3d(axisVectors[direction], viewport);
const farSide = projectArrayPosition3d(
axisVectors[direction].map((coordinate) => -coordinate),
viewport,
);
assertNear(cameraSide.x, 200, 1e-12, direction + " camera-side x");
assertNear(cameraSide.y, 120, 1e-12, direction + " camera-side y");
assertNear(cameraSide.depth, 1, 1e-12, direction + " camera-side depth");
assertNear(farSide.x, 200, 1e-12, direction + " far-side x");
assertNear(farSide.y, 120, 1e-12, direction + " far-side y");
assertNear(farSide.depth, -1, 1e-12, direction + " far-side depth");
}
});
test("Array canvas +Z preset은 +X를 오른쪽, +Y를 위쪽으로 투영한다", () => {
const positiveZView = projectionViewport(orbitForViewDirection("+z"));
const positiveX = projectArrayPosition3d([1, 0, 0], positiveZView);
const positiveY = projectArrayPosition3d([0, 1, 0], positiveZView);
const positiveZ = projectArrayPosition3d([0, 0, 1], positiveZView);
assertNear(positiveX.x, 300, 1e-12, "+X projects right");
assertNear(positiveX.y, 120, 1e-12, "+X stays vertically centered");
assertNear(positiveY.x, 200, 1e-12, "+Y stays horizontally centered");
assertNear(positiveY.y, 20, 1e-12, "+Y projects up");
assertNear(positiveZ.x, 200, 1e-12, "+Z stays horizontally centered");
assertNear(positiveZ.y, 120, 1e-12, "+Z stays vertically centered");
assertNear(positiveZ.depth, 1, 1e-12, "+Z points toward the viewer");
});
test("Array canvas iso preset은 유한하고 비퇴화된 orthographic view다", () => {
const implicitIso = orbitForViewDirection();
const explicitIso = orbitForViewDirection("iso");
assert(Number.isFinite(explicitIso.yaw), "iso yaw is finite");
assert(Number.isFinite(explicitIso.pitch), "iso pitch is finite");
assert(Math.abs(explicitIso.pitch) < Math.PI / 2, "iso pitch avoids a pole");
assertNear(explicitIso.yaw, -Math.PI / 4, 1e-12, "iso yaw is canonical");
assertNear(
explicitIso.pitch,
Math.asin(1 / Math.sqrt(3)),
1e-12,
"iso pitch is equally inclined to XYZ",
);
assertNear(implicitIso.yaw, explicitIso.yaw, 1e-12, "default yaw is iso");
assertNear(implicitIso.pitch, explicitIso.pitch, 1e-12, "default pitch is iso");
const viewport = projectionViewport(explicitIso);
const basis = [
projectArrayPosition3d([1, 0, 0], viewport),
projectArrayPosition3d([0, 1, 0], viewport),
projectArrayPosition3d([0, 0, 1], viewport),
];
for (const [index, point] of basis.entries()) {
assert(
[point.x, point.y, point.depth].every(Number.isFinite),
"iso basis projection " + index + " is finite",
);
assert(
Math.hypot(point.x - 200, point.y - 120) > 1,
"iso basis projection " + index + " does not collapse to the origin",
);
}
const xVector = [basis[0].x - 200, basis[0].y - 120];
const yVector = [basis[1].x - 200, basis[1].y - 120];
const zVector = [basis[2].x - 200, basis[2].y - 120];
assertNear(
Math.hypot(...xVector),
Math.hypot(...yVector),
1e-10,
"iso X/Y projected unit lengths match",
);
assertNear(
Math.hypot(...xVector),
Math.hypot(...zVector),
1e-10,
"iso X/Z projected unit lengths match",
);
assert(
Math.abs(xVector[0] * yVector[1] - xVector[1] * yVector[0]) > 1,
"iso projected X/Y basis spans a plane",
);
});
test("Array canvas orbit preset은 알 수 없는 direction을 거부한다", () => {
let error = null;
try {
orbitForViewDirection("front");
} catch (caught) {
error = caught;
}
assert(error instanceof RangeError, "invalid direction throws RangeError");
assert(error.message.includes("iso"), "error lists the supported directions");
assert(error.message.includes("+z"), "error includes +Z");
});
+110
View File
@@ -67,6 +67,116 @@ test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다
}
});
test("원형과 Fermat preset은 비정사각 경계에서도 실제 내접원 좌표를 만든다", () => {
const aperture = { width: 0.5, height: 0.3 };
const edgeInsetM = 0.02;
const expectedRadius = 0.13;
const count = 12;
for (const type of ["ring", "spiral"]) {
const rectangle = generatePresetMicrophones({
type,
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
const ellipse = generatePresetMicrophones({
type,
aperture,
shape: "ellipse",
edgeInsetM,
count,
});
assertEqual(
JSON.stringify(rectangle),
JSON.stringify(ellipse),
type + " coordinates describe the preset instead of tracing the boundary shape",
);
assert(
rectangle.every((microphone) =>
positionInsideAperture(microphone.position, aperture, "rectangle") &&
positionInsideAperture(microphone.position, aperture, "ellipse")),
type + " inscribed-circle coordinates fit either generation boundary",
);
}
const ring = generatePresetMicrophones({
type: "ring",
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
for (const microphone of ring) {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
expectedRadius,
1e-8,
"ring radius",
);
}
const spiral = generatePresetMicrophones({
type: "spiral",
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
spiral.forEach((microphone, index) => {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
expectedRadius * Math.sqrt((index + 0.5) / count),
1e-8,
"equal-area Fermat radius " + index,
);
});
assertEqual(
generatePresetMicrophones({ type: "spiral", aperture, count: 1 })[0].position.join(","),
"0,0,0",
"single-microphone spiral is centered",
);
});
test("Archimedean preset은 중심부터 내접원까지 선형 반경의 실제 나선을 만든다", () => {
const aperture = { width: 0.5, height: 0.3 };
const edgeInsetM = 0.02;
const maximumRadius = 0.13;
const turns = 2.5;
const count = 11;
const microphones = generatePresetMicrophones({
type: "archimedean",
aperture,
shape: "rectangle",
edgeInsetM,
turns,
count,
});
assertEqual(microphones.length, count, "Archimedean count");
microphones.forEach((microphone, index) => {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
maximumRadius * index / (count - 1),
1e-8,
"Archimedean linear radius " + index,
);
});
assertEqual(microphones[0].position.join(","), "0,0,0", "spiral starts at center");
assertNear(microphones.at(-1).position[0], -maximumRadius, 1e-8, "2.5 turns end at -x");
assertNear(microphones.at(-1).position[1], 0, 1e-8, "2.5 turns end on x axis");
assertThrows(
() => generatePresetMicrophones({
type: "archimedean",
aperture,
turns: 0,
count,
}),
"0.25",
"Archimedean turn lower bound",
);
});
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => {
const aperture = { width: 0.45, height: 0.3 };
const microphones = generatePresetMicrophones({
+479
View File
@@ -1,6 +1,8 @@
import {
computeReceiveBeamPattern,
computeReceiveBeamPatternSphere,
receiveBeamPatternDirection,
receiveBeamPatternSphereDirection,
} from "/packages/acoustics-core/beam-pattern.js";
function assertNear(actual, expected, tolerance, label) {
@@ -413,3 +415,480 @@ test("co-located pattern accepts an exact sample-only evaluation cap", () => {
}
assert(error instanceof RangeError, "strict cap rejects one fewer evaluation");
});
function sphereFlatIndex(pattern, azimuthDeg, elevationDeg) {
let azimuthIndex = -1;
let elevationIndex = -1;
for (let index = 0; index < pattern.azimuthAxisDeg.length; index += 1) {
if (Math.abs(pattern.azimuthAxisDeg[index] - azimuthDeg) < 1e-10) {
azimuthIndex = index;
break;
}
}
for (let index = 0; index < pattern.elevationAxisDeg.length; index += 1) {
if (Math.abs(pattern.elevationAxisDeg[index] - elevationDeg) < 1e-10) {
elevationIndex = index;
break;
}
}
assert(azimuthIndex >= 0, "requested azimuth is present on sphere grid");
assert(elevationIndex >= 0, "requested elevation is present on sphere grid");
return elevationIndex * pattern.azimuthSampleCount + azimuthIndex;
}
test("sphere directions use standard azimuth and elevation coordinates", () => {
const cases = [
[0, 0, [1, 0, 0]],
[90, 0, [0, 1, 0]],
[180, 0, [-1, 0, 0]],
[270, 0, [0, -1, 0]],
[37, 90, [0, 0, 1]],
[241, -90, [0, 0, -1]],
];
for (const [azimuthDeg, elevationDeg, expected] of cases) {
const direction = receiveBeamPatternSphereDirection(
azimuthDeg,
elevationDeg,
);
direction.forEach((value, axis) =>
assertNear(
value,
expected[axis],
1e-12,
"sphere direction axis " + axis,
));
}
const diagonal = receiveBeamPatternSphereDirection(45, 30);
assertNear(diagonal[0], Math.sqrt(6) / 4, 1e-12, "diagonal x");
assertNear(diagonal[1], Math.sqrt(6) / 4, 1e-12, "diagonal y");
assertNear(diagonal[2], 0.5, 1e-12, "diagonal z");
});
test("one microphone returns an unshaped raw full-sphere grid", () => {
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[12.5, -7, 3]]),
frequencyHz: 49_125,
speedOfSound: 343,
azimuthSampleCount: 12,
elevationSampleCount: 7,
maximumEvaluations: 84,
});
assertEqual(pattern.steeringAzimuthDeg, 0, "default sphere steering azimuth");
assertEqual(pattern.steeringElevationDeg, 90, "default sphere steering is +Z");
assertEqual(pattern.steeringDirection.join(","), "0,0,1", "default direction");
assertEqual(pattern.gridShape.join(","), "7,12", "top-level grid shape");
assertEqual(
pattern.flatIndexing,
"elevation-major-row-major",
"top-level flat indexing",
);
assertEqual(pattern.grid.shape.join(","), "7,12", "elevation-major shape");
assertEqual(pattern.microphoneCount, 1, "sphere microphone provenance");
assertEqual(pattern.sampleCount, 84, "sphere sample product");
assertEqual(pattern.evaluationCount, 84, "exact sphere evaluation count");
assertEqual(pattern.responsePower.length, 84, "raw output sample count");
assertEqual(pattern.azimuthAxisDeg.at(-1), 330, "360 endpoint is not duplicated");
assertEqual(pattern.elevationAxisDeg[0], -90, "south pole is included");
assertEqual(pattern.elevationAxisDeg.at(-1), 90, "north pole is included");
assertEqual(pattern.grid.interpolation, "none", "no interpolation");
assertEqual(pattern.grid.smoothing, "none", "no smoothing");
assert(
!pattern.grid.sampledMaximumNormalization,
"sampled maximum normalization is disabled",
);
assert(pattern.angularSampling.resolutionCriterionSatisfied, "zero aperture resolves");
for (let index = 0; index < pattern.sampleCount; index += 1) {
assertNear(pattern.responseReal[index], 1, 0, "single-mic raw real");
assertNear(pattern.responseImaginary[index], 0, 0, "single-mic raw imaginary");
assertNear(pattern.responsePower[index], 1, 0, "single-mic raw power");
assertNear(pattern.responseLinear[index], 1, 0, "single-mic raw magnitude");
assertNear(pattern.responseDb[index], 0, 0, "single-mic raw dB");
assertNear(pattern.displayDb[index], 0, 0, "single-mic display dB");
}
});
test("half-wavelength X baseline has the expected three-dimensional null surface", () => {
const speedOfSound = 343;
const frequencyHz = 1_000;
const spacing = speedOfSound / frequencyHz / 2;
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[-spacing / 2, 0, 0], [spacing / 2, 0, 0]]),
frequencyHz,
speedOfSound,
azimuthSampleCount: 72,
elevationSampleCount: 37,
});
const positiveX = sphereFlatIndex(pattern, 0, 0);
const positiveY = sphereFlatIndex(pattern, 90, 0);
const negativeX = sphereFlatIndex(pattern, 180, 0);
const northPole = sphereFlatIndex(pattern, 0, 90);
const southPole = sphereFlatIndex(pattern, 0, -90);
assertNear(pattern.responseLinear[positiveX], 0, 1e-12, "+X endfire null");
assertNear(pattern.responseLinear[negativeX], 0, 1e-12, "-X endfire null");
assertNear(pattern.responseLinear[positiveY], 1, 1e-12, "+Y broadside");
assertNear(pattern.responseLinear[northPole], 1, 1e-12, "+Z steering response");
assertNear(pattern.responseLinear[southPole], 1, 1e-12, "-Z front-back tie");
assert(
pattern.angularSampling.resolutionCriterionSatisfied,
"five-degree grid satisfies the disclosed cell-diagonal phase criterion",
);
const expectedDiagonalChord = 2 * Math.hypot(
Math.sin(2.5 * Math.PI / 180),
Math.cos(2.5 * Math.PI / 180) * Math.sin(2.5 * Math.PI / 180),
);
assertNear(
pattern.angularSampling.maximumCellDiagonalPhaseStepRadBound,
Math.PI * expectedDiagonalChord,
1e-12,
"full-aperture cell-diagonal phase bound",
);
for (let azimuthIndex = 1; azimuthIndex < pattern.azimuthSampleCount; azimuthIndex += 1) {
assertNear(
pattern.responsePower[northPole + azimuthIndex],
pattern.responsePower[northPole],
0,
"north-pole azimuth samples are physically coincident",
);
assertNear(
pattern.responsePower[southPole + azimuthIndex],
pattern.responsePower[southPole],
0,
"south-pole azimuth samples are physically coincident",
);
}
});
test("sphere raw complex response uses all three coordinates and the receive sign", () => {
const speedOfSound = 343;
const frequencyHz = 1_000;
const wavelengthM = speedOfSound / frequencyHz;
const positions = [
[0, 0, 0],
[wavelengthM / 4, 0, wavelengthM / 16],
[0, wavelengthM / 8, 3 * wavelengthM / 16],
];
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions(positions),
frequencyHz,
speedOfSound,
steeringAzimuthDeg: 0,
steeringElevationDeg: 90,
azimuthSampleCount: 8,
elevationSampleCount: 7,
});
const targetDirection = receiveBeamPatternSphereDirection(45, 30);
const steeringDirection = [0, 0, 1];
const centroid = [0, 0, 0];
for (const position of positions) {
for (let axis = 0; axis < 3; axis += 1) {
centroid[axis] += position[axis] / positions.length;
}
}
let expectedReal = 0;
let expectedImaginary = 0;
for (const position of positions) {
const centered = position.map((coordinate, axis) => coordinate - centroid[axis]);
const phase = 2 * Math.PI / wavelengthM * (
centered[0] * (targetDirection[0] - steeringDirection[0]) +
centered[1] * (targetDirection[1] - steeringDirection[1]) +
centered[2] * (targetDirection[2] - steeringDirection[2])
);
expectedReal += Math.cos(phase) / positions.length;
expectedImaginary += Math.sin(phase) / positions.length;
}
const targetIndex = sphereFlatIndex(pattern, 45, 30);
assertNear(pattern.responseReal[targetIndex], expectedReal, 1e-12, "3D raw real");
assertNear(
pattern.responseImaginary[targetIndex],
expectedImaginary,
1e-12,
"3D raw imaginary sign",
);
assertNear(
pattern.responsePower[targetIndex],
expectedReal * expectedReal + expectedImaginary * expectedImaginary,
1e-12,
"3D raw power",
);
});
test("sphere samples agree with the existing XZ great-circle contract", () => {
const array = arrayFromPositions([
[-0.12, 0.03, 0.04],
[0.04, -0.08, 0.02],
[0.15, 0.07, -0.11],
]);
const shared = {
array,
frequencyHz: 3_200,
speedOfSound: 343,
};
const cut = computeReceiveBeamPattern({
...shared,
cut: "xz",
steeringAngleDeg: 0,
sampleCount: 360,
});
const sphere = computeReceiveBeamPatternSphere({
...shared,
steeringAzimuthDeg: 0,
steeringElevationDeg: 90,
azimuthSampleCount: 4,
elevationSampleCount: 3,
});
const mappings = [
[0, 0, 90],
[90, 0, 0],
[180, 0, -90],
[270, 180, 0],
];
for (const [cutAngleDeg, azimuthDeg, elevationDeg] of mappings) {
const cutIndex = [...cut.anglesDeg].findIndex(
(angleDeg) => angleDeg === cutAngleDeg,
);
const sphereIndex = sphereFlatIndex(sphere, azimuthDeg, elevationDeg);
assert(cutIndex >= 0, "mapped XZ angle is sampled");
assertNear(
sphere.responseReal[sphereIndex],
cut.responseReal[cutIndex],
1e-12,
"sphere/XZ raw real at " + cutAngleDeg,
);
assertNear(
sphere.responseImaginary[sphereIndex],
cut.responseImaginary[cutIndex],
1e-12,
"sphere/XZ raw imaginary at " + cutAngleDeg,
);
assertNear(
sphere.responsePower[sphereIndex],
cut.responsePower[cutIndex],
1e-12,
"sphere/XZ raw power at " + cutAngleDeg,
);
}
});
test("sphere array factor remains invariant to translating a three-dimensional array", () => {
const basePositions = [
[-0.12, 0.03, 0.04],
[0.04, -0.08, 0.02],
[0.15, 0.07, -0.11],
];
const translation = [10, -20, 5];
const translatedPositions = basePositions.map((position) =>
position.map((coordinate, axis) => coordinate + translation[axis]));
const options = {
frequencyHz: 7_500,
speedOfSound: 343,
steeringAzimuthDeg: 45,
steeringElevationDeg: 30,
azimuthSampleCount: 8,
elevationSampleCount: 7,
};
const base = computeReceiveBeamPatternSphere({
...options,
array: arrayFromPositions(basePositions),
});
const translated = computeReceiveBeamPatternSphere({
...options,
array: arrayFromPositions(translatedPositions),
});
for (let index = 0; index < base.sampleCount; index += 1) {
assertNear(
translated.responseReal[index],
base.responseReal[index],
1e-11,
"translation-invariant sphere real " + index,
);
assertNear(
translated.responseImaginary[index],
base.responseImaginary[index],
1e-11,
"translation-invariant sphere imaginary " + index,
);
}
});
test("sphere response preserves analog frequency and display floor only affects display dB", () => {
const highFrequencyHz = 49_125;
const speedOfSound = 343;
const halfHighWavelength = speedOfSound / (2 * highFrequencyHz);
const array = arrayFromPositions([
[-halfHighWavelength / 2, 0, 0],
[halfHighWavelength / 2, 0, 0],
]);
const shared = {
array,
speedOfSound,
steeringElevationDeg: 90,
azimuthSampleCount: 4,
elevationSampleCount: 3,
};
const highAnalog = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: highFrequencyHz,
dbFloor: -30,
});
const highAnalogDeepFloor = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: highFrequencyHz,
dbFloor: -100,
});
const foldedTemporalAlias = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: 1_125,
});
const positiveX = sphereFlatIndex(highAnalog, 0, 0);
assertEqual(highAnalog.frequencyHz, highFrequencyHz, "analog frequency is preserved");
assertNear(
highAnalog.wavelengthM,
speedOfSound / highFrequencyHz,
1e-15,
"sphere uses analog wavelength",
);
assert(
Math.abs(
highAnalog.responseLinear[positiveX] -
foldedTemporalAlias.responseLinear[positiveX]
) > 0.1,
"sphere response is not computed from a folded ADC alias",
);
for (let index = 0; index < highAnalog.sampleCount; index += 1) {
assertNear(
highAnalog.responsePower[index],
highAnalogDeepFloor.responsePower[index],
0,
"sphere raw power is display-floor independent",
);
}
assertEqual(highAnalog.displayDb[positiveX], -30, "sphere shallow display floor");
assertEqual(
highAnalogDeepFloor.displayDb[positiveX],
-100,
"sphere deep display floor",
);
assert(
highAnalog.responseDb[positiveX] < -300,
"sphere raw null is retained below display floors",
);
});
test("coarse sphere grids expose failed resolution provenance without shaping raw samples", () => {
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[-0.5, 0, 0], [0.5, 0, 0]]),
frequencyHz: 100_000,
speedOfSound: 343,
azimuthSampleCount: 12,
elevationSampleCount: 7,
});
assert(
!pattern.angularSampling.resolutionCriterionSatisfied,
"coarse full-sphere phase-step criterion fails",
);
assert(
pattern.angularSampling.maximumCellDiagonalPhaseStepRadBound >
pattern.angularSampling.phaseStepLimitRad,
"failed cell-diagonal phase bound is exposed",
);
assertEqual(pattern.responsePower.length, 84, "unresolved raw sphere is retained");
assertEqual(
pattern.angularSampling.interpretation,
"sampling-bound-only-not-proof-of-complete-lobe-detection",
"resolution claim remains limited",
);
});
test("sphere preflight checks axis, product, baseline, and evaluation caps before output allocation", () => {
const single = arrayFromPositions([[0, 0, 0]]);
const exact = computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumEvaluations: 12,
});
assertEqual(exact.evaluationCount, 12, "exact sphere evaluation cap is accepted");
assertEqual(
exact.workload.returnedFloat64Bytes,
(12 * 6 + 4 + 3) * Float64Array.BYTES_PER_ELEMENT,
"returned allocation provenance",
);
let evaluationError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumEvaluations: 11,
});
} catch (error) {
evaluationError = error;
}
assert(evaluationError instanceof RangeError, "sphere evaluation cap rejects work");
assert(evaluationError.message.includes("12"), "evaluation requirement is explicit");
let productError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 720,
elevationSampleCount: 721,
});
} catch (error) {
productError = error;
}
assert(productError instanceof RangeError, "sphere sample product is bounded");
assert(
productError.message.includes("262144"),
"sphere sample-product limit is explicit",
);
let axisError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 1_441,
elevationSampleCount: 3,
});
} catch (error) {
axisError = error;
}
assert(axisError instanceof RangeError, "sphere azimuth axis is bounded");
let baselineError = null;
try {
computeReceiveBeamPatternSphere({
array: arrayFromPositions([[0, 0, 0], [0.1, 0, 0]]),
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumBaselineComparisons: 0,
});
} catch (error) {
baselineError = error;
}
assert(baselineError instanceof RangeError, "sphere baseline work is bounded");
assert(
baselineError.message.includes("1 spatial-baseline"),
"sphere baseline requirement is explicit",
);
let steeringError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
steeringElevationDeg: 91,
azimuthSampleCount: 4,
elevationSampleCount: 3,
});
} catch (error) {
steeringError = error;
}
assert(steeringError instanceof RangeError, "invalid sphere elevation is rejected");
});