fix: make circular and spiral presets truthful
This commit is contained in:
@@ -107,7 +107,7 @@ causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)
|
|||||||
|
|
||||||
1. 시작 화면에서 **Array Builder**를 엽니다.
|
1. 시작 화면에서 **Array Builder**를 엽니다.
|
||||||
2. aperture 너비·높이와 프리셋을 배치할 **생성 경계**(사각형 또는 타원)를 정합니다.
|
2. aperture 너비·높이와 프리셋을 배치할 **생성 경계**(사각형 또는 타원)를 정합니다.
|
||||||
3. **격자**, **선형**, **원형**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
|
3. **격자**, **선형**, **원형 링**, **Archimedean 나선**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
|
||||||
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
|
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
|
||||||
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로
|
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로
|
||||||
수정합니다.
|
수정합니다.
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ aperture 크기를 입력하는 것만으로 기존 좌표가 자동 확대·축
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 격자 | 행·열, 경계 여백 | 아래 행부터 위로, 각 행은 왼쪽에서 오른쪽으로 생성 |
|
| 격자 | 행·열, 경계 여백 | 아래 행부터 위로, 각 행은 왼쪽에서 오른쪽으로 생성 |
|
||||||
| 선형 | 마이크 수, x/y 축 | 선택한 중심축을 따라 일정 간격으로 생성 |
|
| 선형 | 마이크 수, x/y 축 | 선택한 중심축을 따라 일정 간격으로 생성 |
|
||||||
| 원형 | 마이크 수 | 사각 경계는 사각 둘레, 타원 경계는 타원 둘레에 균일 배치 |
|
| 원형 링 | 마이크 수 | 경계의 짧은 유효 반지름에 맞춘 실제 내접원 위에 동일 각도 배치 |
|
||||||
| 나선 | 마이크 수 | golden angle을 사용하는 결정론적 Fermat 나선 |
|
| Archimedean 나선 | 마이크 수, 회전 수 | 중심에서 시작해 <code>r∝θ</code>로 커지는 한 줄 곡선형 나선 |
|
||||||
|
| Fermat 나선 | 마이크 수 | golden angle과 <code>r∝√n</code>을 쓰는 면적 균등 해바라기형 배열 |
|
||||||
| 균일 랜덤 | 마이크 수, seed, 최소 간격 | 경계 내부에 seed로 재현되는 좌표 생성 |
|
| 균일 랜덤 | 마이크 수, seed, 최소 간격 | 경계 내부에 seed로 재현되는 좌표 생성 |
|
||||||
|
|
||||||
**경계 여백**은 preset 좌표를 aperture 가장자리에서 안쪽으로 띄웁니다. 랜덤 배열은 요청한 최소
|
**경계 여백**은 preset 좌표를 aperture 가장자리에서 안쪽으로 띄웁니다. 랜덤 배열은 요청한 최소
|
||||||
간격을 만족하는 배치를 제한된 횟수 안에 찾지 못하면 현재 배열을 바꾸지 않고 오류를 표시합니다.
|
간격을 만족하는 배치를 제한된 횟수 안에 찾지 못하면 현재 배열을 바꾸지 않고 오류를 표시합니다.
|
||||||
같은 크기·형상·수·seed·간격을 사용하면 같은 랜덤 좌표가 생성됩니다.
|
같은 크기·형상·수·seed·간격을 사용하면 같은 랜덤 좌표가 생성됩니다.
|
||||||
|
원형과 두 나선은 사각/타원 경계 자체를 변형해 따라가지 않고, 어느 경계에도 들어가는 실제 내접원을
|
||||||
|
기준으로 만듭니다. 따라서 비정사각 aperture에서도 원이 타원이나 사각 둘레로 바뀌지 않습니다.
|
||||||
|
|
||||||
프리셋 생성은 현재 마이크 목록 전체를 교체합니다. 필요한 기존 좌표나 채널 보정이 있다면 먼저
|
프리셋 생성은 현재 마이크 목록 전체를 교체합니다. 필요한 기존 좌표나 채널 보정이 있다면 먼저
|
||||||
Array JSON을 저장하세요.
|
Array JSON을 저장하세요.
|
||||||
|
|||||||
@@ -339,8 +339,9 @@
|
|||||||
<select id="preset-type">
|
<select id="preset-type">
|
||||||
<option value="grid">격자</option>
|
<option value="grid">격자</option>
|
||||||
<option value="linear">선형</option>
|
<option value="linear">선형</option>
|
||||||
<option value="ring">원형</option>
|
<option value="ring">원형 링</option>
|
||||||
<option value="spiral">나선</option>
|
<option value="archimedean">나선 · Archimedean</option>
|
||||||
|
<option value="spiral">Fermat 나선 · 해바라기</option>
|
||||||
<option value="random">균일 랜덤</option>
|
<option value="random">균일 랜덤</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -356,6 +357,10 @@
|
|||||||
<option value="y">세로 (y)</option>
|
<option value="y">세로 (y)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="field" id="preset-columns-field">
|
||||||
<span>열</span>
|
<span>열</span>
|
||||||
<input id="preset-columns" type="number" min="1" max="1024" step="1" value="4" inputmode="numeric" />
|
<input id="preset-columns" type="number" min="1" max="1024" step="1" value="4" inputmode="numeric" />
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ const requiredIds = [
|
|||||||
"array-builder-split-handle", "array-input", "export-button", "array-id",
|
"array-builder-split-handle", "array-input", "export-button", "array-id",
|
||||||
"array-name", "array-description", "aperture-width", "aperture-height",
|
"array-name", "array-description", "aperture-width", "aperture-height",
|
||||||
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
"aperture-shape", "preset-type", "preset-count", "preset-columns",
|
||||||
"preset-rows", "preset-seed", "preset-minimum-spacing",
|
"preset-rows", "preset-turns", "preset-seed", "preset-minimum-spacing",
|
||||||
|
"preset-hint",
|
||||||
"generate-preset-button", "array-canvas", "select-tool-button",
|
"generate-preset-button", "array-canvas", "select-tool-button",
|
||||||
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
|
"add-tool-button", "array-view-2d-button", "array-view-3d-button",
|
||||||
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
||||||
@@ -1129,6 +1130,7 @@ function syncPresetControls() {
|
|||||||
const grid = type === "grid";
|
const grid = type === "grid";
|
||||||
const random = type === "random";
|
const random = type === "random";
|
||||||
const linear = type === "linear";
|
const linear = type === "linear";
|
||||||
|
const archimedean = type === "archimedean";
|
||||||
for (const element of [elements["preset-columns"], elements["preset-rows"]]) {
|
for (const element of [elements["preset-columns"], elements["preset-rows"]]) {
|
||||||
element.closest(".field")?.toggleAttribute("hidden", !grid);
|
element.closest(".field")?.toggleAttribute("hidden", !grid);
|
||||||
}
|
}
|
||||||
@@ -1136,6 +1138,16 @@ function syncPresetControls() {
|
|||||||
elements["preset-seed"].closest(".field")?.toggleAttribute("hidden", !random);
|
elements["preset-seed"].closest(".field")?.toggleAttribute("hidden", !random);
|
||||||
elements["preset-minimum-spacing"].closest(".field")?.toggleAttribute("hidden", !random);
|
elements["preset-minimum-spacing"].closest(".field")?.toggleAttribute("hidden", !random);
|
||||||
elements["preset-axis"]?.closest(".field")?.toggleAttribute("hidden", !linear);
|
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() {
|
function syncHistoryButtons() {
|
||||||
@@ -1277,6 +1289,12 @@ function presetOptions() {
|
|||||||
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
|
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
|
||||||
}
|
}
|
||||||
if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x";
|
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") {
|
if (type === "random") {
|
||||||
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
|
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
|
||||||
options.minimumSpacingM = finiteInput(
|
options.minimumSpacingM = finiteInput(
|
||||||
|
|||||||
+42
-21
@@ -1,6 +1,13 @@
|
|||||||
export const MAX_MICROPHONES = 1024;
|
export const MAX_MICROPHONES = 1024;
|
||||||
export const APERTURE_SHAPES = Object.freeze(["rectangle", "ellipse"]);
|
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({
|
const COORDINATE_SYSTEM = Object.freeze({
|
||||||
handedness: "right",
|
handedness: "right",
|
||||||
@@ -136,41 +143,51 @@ function linearPositions(options, bounds, shape) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function rectanglePerimeterPosition(ratio, halfWidth, halfHeight) {
|
function inscribedCircleRadius(bounds) {
|
||||||
const width = halfWidth * 2;
|
return Math.min(bounds.halfWidth, bounds.halfHeight);
|
||||||
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 ringPositions(options, bounds, shape) {
|
function ringPositions(options, bounds) {
|
||||||
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
|
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
|
||||||
if (count === 1) return [[0, 0, 0]];
|
if (count === 1) return [[0, 0, 0]];
|
||||||
|
const radius = inscribedCircleRadius(bounds);
|
||||||
return Array.from({ length: count }, (_, index) => {
|
return Array.from({ length: count }, (_, index) => {
|
||||||
const ratio = index / count;
|
const ratio = index / count;
|
||||||
if (shape === "rectangle") {
|
|
||||||
return rectanglePerimeterPosition(ratio, bounds.halfWidth, bounds.halfHeight);
|
|
||||||
}
|
|
||||||
const angle = ratio * Math.PI * 2;
|
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) {
|
function spiralPositions(options, bounds) {
|
||||||
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
|
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) => {
|
return Array.from({ length: count }, (_, index) => {
|
||||||
const radius = Math.sqrt((index + 0.5) / count);
|
const radius = Math.sqrt((index + 0.5) / count);
|
||||||
const angle = index * GOLDEN_ANGLE;
|
const angle = index * GOLDEN_ANGLE;
|
||||||
return [
|
return [
|
||||||
bounds.halfWidth * radius * Math.cos(angle),
|
maximumRadius * radius * Math.cos(angle),
|
||||||
bounds.halfHeight * radius * Math.sin(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,
|
0,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -286,7 +303,10 @@ export function generatePresetMicrophones(options = {}) {
|
|||||||
const bounds = presetBounds(options.aperture, options.edgeInsetM);
|
const bounds = presetBounds(options.aperture, options.edgeInsetM);
|
||||||
let positions;
|
let positions;
|
||||||
if (options.type === "linear") positions = linearPositions(options, bounds, shape);
|
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 === "spiral") positions = spiralPositions(options, bounds);
|
||||||
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
|
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
|
||||||
else positions = gridPositions(options, bounds, shape);
|
else positions = gridPositions(options, bounds, shape);
|
||||||
@@ -320,6 +340,7 @@ export function createArrayDefinition(options = {}) {
|
|||||||
minimumSpacingM: options.minimumSpacingM,
|
minimumSpacingM: options.minimumSpacingM,
|
||||||
edgeInsetM: options.edgeInsetM,
|
edgeInsetM: options.edgeInsetM,
|
||||||
axis: options.axis,
|
axis: options.axis,
|
||||||
|
turns: options.turns,
|
||||||
});
|
});
|
||||||
return arrayDefinition;
|
return arrayDefinition;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@
|
|||||||
<h2>내 마이크 배열 만들기</h2>
|
<h2>내 마이크 배열 만들기</h2>
|
||||||
<ol class="guide-steps">
|
<ol class="guide-steps">
|
||||||
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
|
<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> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
|
||||||
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.</li>
|
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.</li>
|
||||||
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
|
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
|
||||||
|
|||||||
@@ -572,6 +572,53 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
() => documentObject.documentElement.dataset.arrayValid === "true",
|
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||||
"ellipse ring validation",
|
"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-type"), "random");
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
|
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
|
||||||
@@ -585,7 +632,7 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
|
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
|
||||||
|
|
||||||
documentObject.querySelector("#undo-button").click();
|
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();
|
documentObject.querySelector("#redo-button").click();
|
||||||
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
|
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
|
||||||
|
|
||||||
|
|||||||
@@ -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 수를 유지하며 경계 안에 배치된다", () => {
|
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => {
|
||||||
const aperture = { width: 0.45, height: 0.3 };
|
const aperture = { width: 0.45, height: 0.3 };
|
||||||
const microphones = generatePresetMicrophones({
|
const microphones = generatePresetMicrophones({
|
||||||
|
|||||||
Reference in New Issue
Block a user