Compare commits
6 Commits
6804eca6ed
...
7172d5654a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7172d5654a | |||
| 2a2e192196 | |||
| 51c7bbd859 | |||
| 9d937160eb | |||
| c289af366c | |||
| dda72739a9 |
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user