fix: make circular and spiral presets truthful

This commit is contained in:
2026-08-30 11:18:51 +09:00
parent 9d937160eb
commit 51c7bbd859
8 changed files with 233 additions and 29 deletions
+1 -1
View File
@@ -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 입력으로
수정합니다. 수정합니다.
+5 -2
View File
@@ -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을 저장하세요.
+7 -2
View File
@@ -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" />
+19 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+48 -1
View File
@@ -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");
+110
View File
@@ -67,6 +67,116 @@ test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다
} }
}); });
test("원형과 Fermat preset은 비정사각 경계에서도 실제 내접원 좌표를 만든다", () => {
const aperture = { width: 0.5, height: 0.3 };
const edgeInsetM = 0.02;
const expectedRadius = 0.13;
const count = 12;
for (const type of ["ring", "spiral"]) {
const rectangle = generatePresetMicrophones({
type,
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
const ellipse = generatePresetMicrophones({
type,
aperture,
shape: "ellipse",
edgeInsetM,
count,
});
assertEqual(
JSON.stringify(rectangle),
JSON.stringify(ellipse),
type + " coordinates describe the preset instead of tracing the boundary shape",
);
assert(
rectangle.every((microphone) =>
positionInsideAperture(microphone.position, aperture, "rectangle") &&
positionInsideAperture(microphone.position, aperture, "ellipse")),
type + " inscribed-circle coordinates fit either generation boundary",
);
}
const ring = generatePresetMicrophones({
type: "ring",
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
for (const microphone of ring) {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
expectedRadius,
1e-8,
"ring radius",
);
}
const spiral = generatePresetMicrophones({
type: "spiral",
aperture,
shape: "rectangle",
edgeInsetM,
count,
});
spiral.forEach((microphone, index) => {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
expectedRadius * Math.sqrt((index + 0.5) / count),
1e-8,
"equal-area Fermat radius " + index,
);
});
assertEqual(
generatePresetMicrophones({ type: "spiral", aperture, count: 1 })[0].position.join(","),
"0,0,0",
"single-microphone spiral is centered",
);
});
test("Archimedean preset은 중심부터 내접원까지 선형 반경의 실제 나선을 만든다", () => {
const aperture = { width: 0.5, height: 0.3 };
const edgeInsetM = 0.02;
const maximumRadius = 0.13;
const turns = 2.5;
const count = 11;
const microphones = generatePresetMicrophones({
type: "archimedean",
aperture,
shape: "rectangle",
edgeInsetM,
turns,
count,
});
assertEqual(microphones.length, count, "Archimedean count");
microphones.forEach((microphone, index) => {
assertNear(
Math.hypot(microphone.position[0], microphone.position[1]),
maximumRadius * index / (count - 1),
1e-8,
"Archimedean linear radius " + index,
);
});
assertEqual(microphones[0].position.join(","), "0,0,0", "spiral starts at center");
assertNear(microphones.at(-1).position[0], -maximumRadius, 1e-8, "2.5 turns end at -x");
assertNear(microphones.at(-1).position[1], 0, 1e-8, "2.5 turns end on x axis");
assertThrows(
() => generatePresetMicrophones({
type: "archimedean",
aperture,
turns: 0,
count,
}),
"0.25",
"Archimedean turn lower bound",
);
});
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => { 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({