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
+5 -2
View File
@@ -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을 저장하세요.
+7 -2
View File
@@ -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" />
+19 -1
View File
@@ -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
View File
@@ -1,6 +1,13 @@
export const MAX_MICROPHONES = 1024;
export const APERTURE_SHAPES = Object.freeze(["rectangle", "ellipse"]);
export const PRESET_TYPES = Object.freeze(["grid", "linear", "ring", "spiral", "random"]);
export const PRESET_TYPES = Object.freeze([
"grid",
"linear",
"ring",
"archimedean",
"spiral",
"random",
]);
const COORDINATE_SYSTEM = Object.freeze({
handedness: "right",
@@ -136,41 +143,51 @@ function linearPositions(options, bounds, shape) {
});
}
function rectanglePerimeterPosition(ratio, halfWidth, halfHeight) {
const width = halfWidth * 2;
const height = halfHeight * 2;
const perimeter = 2 * (width + height);
let distance = ratio * perimeter;
if (distance <= width) return [-halfWidth + distance, -halfHeight, 0];
distance -= width;
if (distance <= height) return [halfWidth, -halfHeight + distance, 0];
distance -= height;
if (distance <= width) return [halfWidth - distance, halfHeight, 0];
distance -= width;
return [-halfWidth, halfHeight - distance, 0];
function inscribedCircleRadius(bounds) {
return Math.min(bounds.halfWidth, bounds.halfHeight);
}
function ringPositions(options, bounds, shape) {
function ringPositions(options, bounds) {
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const radius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const ratio = index / count;
if (shape === "rectangle") {
return rectanglePerimeterPosition(ratio, bounds.halfWidth, bounds.halfHeight);
}
const angle = ratio * Math.PI * 2;
return [bounds.halfWidth * Math.cos(angle), bounds.halfHeight * Math.sin(angle), 0];
return [radius * Math.cos(angle), radius * Math.sin(angle), 0];
});
}
function spiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const maximumRadius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const radius = Math.sqrt((index + 0.5) / count);
const angle = index * GOLDEN_ANGLE;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
maximumRadius * radius * Math.cos(angle),
maximumRadius * radius * Math.sin(angle),
0,
];
});
}
function archimedeanSpiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
const turns = finiteNumber(options.turns ?? 2.5, "나선 회전 수");
if (turns < 0.25 || turns > 32) {
throw new RangeError("나선 회전 수는 0.25 이상 32 이하이어야 합니다.");
}
const maximumRadius = inscribedCircleRadius(bounds);
return Array.from({ length: count }, (_, index) => {
const ratio = index / (count - 1);
const angle = ratio * turns * Math.PI * 2;
const radius = maximumRadius * ratio;
return [
radius * Math.cos(angle),
radius * Math.sin(angle),
0,
];
});
@@ -286,7 +303,10 @@ export function generatePresetMicrophones(options = {}) {
const bounds = presetBounds(options.aperture, options.edgeInsetM);
let positions;
if (options.type === "linear") positions = linearPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds);
else if (options.type === "archimedean") {
positions = archimedeanSpiralPositions(options, bounds);
}
else if (options.type === "spiral") positions = spiralPositions(options, bounds);
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
else positions = gridPositions(options, bounds, shape);
@@ -320,6 +340,7 @@ export function createArrayDefinition(options = {}) {
minimumSpacingM: options.minimumSpacingM,
edgeInsetM: options.edgeInsetM,
axis: options.axis,
turns: options.turns,
});
return arrayDefinition;
}