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**를 엽니다.
|
||||
2. aperture 너비·높이와 프리셋을 배치할 **생성 경계**(사각형 또는 타원)를 정합니다.
|
||||
3. **격자**, **선형**, **원형**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
|
||||
3. **격자**, **선형**, **원형 링**, **Archimedean 나선**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
|
||||
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
|
||||
4. 2D 편집에서 **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로
|
||||
수정합니다.
|
||||
|
||||
@@ -40,13 +40,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을 저장하세요.
|
||||
|
||||
@@ -339,8 +339,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>
|
||||
@@ -356,6 +357,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" />
|
||||
|
||||
@@ -38,7 +38,8 @@ 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", "array-view-2d-button", "array-view-3d-button",
|
||||
"canvas-front-badge", "fit-view-button", "zoom-in-button", "zoom-out-button",
|
||||
@@ -1129,6 +1130,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);
|
||||
}
|
||||
@@ -1136,6 +1138,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() {
|
||||
@@ -1277,6 +1289,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(
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@
|
||||
<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>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
|
||||
|
||||
@@ -572,6 +572,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);
|
||||
@@ -585,7 +632,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");
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user