feat: add axis-aligned 3d camera views

This commit is contained in:
2026-08-30 11:40:21 +09:00
parent 51c7bbd859
commit 2a2e192196
12 changed files with 619 additions and 61 deletions
+9 -3
View File
@@ -16,7 +16,8 @@ narrowband far-field 수신 빔 패턴을 XZ·YZ·XY 단면과 3D 보기로 확
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
4. 3D 배열 보기로 전환해 z 배치와 **+Z FRONT** 방향이 의도와 같은지 회전하며 검사합니다.
4. 3D 배열 보기로 전환해 등각 또는 **±X/±Y/±Z에서 보기**를 선택하고 z 배치와
**+Z FRONT** 방향이 의도와 같은지 검사합니다.
5. canvas 아래 **수신 빔 패턴**에서 로그 주파수 slider를 움직이며 세 polar 단면과 3D 패턴의 main
lobe 및 동률 응답을 확인합니다.
6. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
@@ -69,7 +70,10 @@ Array JSON을 저장하세요.
배열 canvas의 2D 모드는 XY 좌표를 배치하는 편집기이고, 3D 모드는 x/y/z 관계와 앞뒤를 회전해 보는
검사기입니다. 좌표 추가·이동·삭제는 2D canvas나 숫자 필드에서 수행합니다. **+Z FRONT** 표시는
양의 z 쪽이 배열의 전면이라는 좌표 기준이며, 이상적 무지향성 마이크에 실제 전후 지향성을 추가하지
않습니다. 3D에서는 drag 또는 방향키로 회전하고 **R**이나 **화면 맞춤**으로 기본 시점을 복원합니다.
않습니다. 3D에서는 시점 선택기로 등각 또는 ±X/±Y/±Z 축에서 원점을 정확히 바라볼 수 있습니다.
여기서 “+X에서 보기”는 카메라가 +X 쪽에 있고 원점을 향한다는 뜻입니다. drag 또는 방향키로
회전하면 **자유 회전**으로 바뀌고, **R**이나 **화면 맞춤**으로 등각 시점을 복원합니다. 이 카메라
조작은 마이크 좌표, undo/dirty 상태와 저장 JSON을 바꾸지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
@@ -96,7 +100,9 @@ dB는 <code>10 log10(|B|²)</code>이며 sampled maximum에 맞춰 결과를 다
- **세 polar 단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는
0°=+X·90°=+Y이며 세 plot을 함께 갱신합니다.
- **3D 패턴:** 같은 물리식을 구면 방향으로 평가해 세 단면 사이의 대각 lobe와 앞뒤 모호성을
회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다.
회전하며 검사합니다. 세 polar 결과를 보간해 만든 가짜 surface가 아닙니다. 표시 시점은 등각 또는
±X/±Y/±Z에서 원점을 보는 방향으로 정렬할 수 있고, 시점 변경은 조향 방향·주파수·계산된 구면
응답을 변경하거나 다시 계산하지 않습니다.
- **조향 방향:** 표시된 좌표 규약에 따라 conventional beamformer가 보는 방향입니다.
- **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은
계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다.
+62 -21
View File
@@ -1,8 +1,8 @@
import { orbitForViewDirection } from "./orbit-view.js";
const MAX_PIXEL_RATIO = 3;
const MAX_SURFACE_VIEW_SAMPLES = 20_000;
const MAX_PITCH_RAD = Math.PI * 0.46;
const DEFAULT_YAW_RAD = -Math.PI / 5;
const DEFAULT_PITCH_RAD = Math.PI / 4.5;
const MAX_PITCH_RAD = Math.PI / 2;
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
@@ -114,11 +114,10 @@ function rotatedPoint(point, yaw, pitch) {
function projectedPoint(point, yaw, pitch, size) {
const rotated = rotatedPoint(point, yaw, pitch);
const perspective = 1 / Math.max(0.68, 1 - rotated[2] * 0.18);
const scale = Math.min(size.width, size.height) * 0.39;
return {
x: size.width / 2 + rotated[0] * scale * perspective,
y: size.height / 2 - rotated[1] * scale * perspective,
x: size.width / 2 + rotated[0] * scale,
y: size.height / 2 - rotated[1] * scale,
depth: rotated[2],
};
}
@@ -133,7 +132,7 @@ function polygonArea(points) {
return Math.abs(area) / 2;
}
export function createReceiveBeamPatternSurfaceView(canvas) {
export function createReceiveBeamPatternSurfaceView(canvas, callbacks = {}) {
if (!canvas?.getContext) {
throw new TypeError("3D 빔 패턴 canvas가 필요합니다.");
}
@@ -146,8 +145,8 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
let surface = null;
let validation = null;
let errorMessage = "";
let yaw = DEFAULT_YAW_RAD;
let pitch = DEFAULT_PITCH_RAD;
let viewDirection = "iso";
let { yaw, pitch } = orbitForViewDirection(viewDirection);
let orbit = null;
let frameId = 0;
let destroyed = false;
@@ -324,6 +323,26 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
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;
@@ -356,12 +375,17 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onPointerMove(event) {
if (!orbit || orbit.pointerId !== event.pointerId) return;
event.preventDefault();
yaw += (event.clientX - orbit.x) * 0.011;
pitch = clamp(
pitch - (event.clientY - orbit.y) * 0.011,
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();
@@ -369,16 +393,33 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
function onKeyDown(event) {
const step = event.shiftKey ? 15 : 5;
if (event.key === "ArrowLeft") yaw -= step * Math.PI / 180;
else if (event.key === "ArrowRight") yaw += step * Math.PI / 180;
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") {
pitch = clamp(pitch + step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
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") {
pitch = clamp(pitch - step * Math.PI / 180, -MAX_PITCH_RAD, MAX_PITCH_RAD);
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") {
yaw = DEFAULT_YAW_RAD;
pitch = DEFAULT_PITCH_RAD;
setViewDirection("iso");
} else return;
event.preventDefault();
schedule();
@@ -399,15 +440,15 @@ export function createReceiveBeamPatternSurfaceView(canvas) {
} else {
view.addEventListener?.("resize", onResize);
}
canvas.dataset.viewAxis = viewDirection;
schedule();
return {
render,
renderError,
setViewDirection,
resetOrbit() {
yaw = DEFAULT_YAW_RAD;
pitch = DEFAULT_PITCH_RAD;
schedule();
return setViewDirection("iso");
},
destroy() {
if (destroyed) return;
+75 -17
View File
@@ -1,13 +1,14 @@
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 });
const DEFAULT_ORBIT = Object.freeze({ yaw: -Math.PI / 5, pitch: Math.PI / 4.5 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -704,7 +705,7 @@ function drawMicrophones3d(context, model, viewport) {
return microphones;
}
function drawArray3d(context, size, model, zoom, orbit) {
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)) {
@@ -735,7 +736,13 @@ function drawArray3d(context, size, model, zoom, orbit) {
context.textBaseline = "top";
context.fillStyle = "rgba(205,225,218,.72)";
context.textAlign = "left";
context.fillText("3D INSPECT - drag to orbit", viewport.plot.x, 8);
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";
@@ -765,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가 필요합니다.");
@@ -782,7 +790,8 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1;
let tool = "select";
let viewMode = "2d";
let orbit = { ...DEFAULT_ORBIT };
let viewDirection = "iso";
let orbit = orbitForViewDirection(viewDirection);
let orbitDrag = null;
let drag = null;
let pendingAdd = null;
@@ -793,6 +802,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
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) {
@@ -807,7 +817,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = viewMode === "3d"
? drawArray3d(context, size, model, zoom, orbit)
? drawArray3d(context, size, model, zoom, orbit, viewDirection)
: drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
@@ -853,9 +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 = { ...DEFAULT_ORBIT };
if (viewMode === "3d") {
orbit = orbitForViewDirection("iso");
updateViewDirection("iso");
}
viewport = null;
schedule();
return zoom;
@@ -918,17 +950,21 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX;
}
function finishOrbit(event) {
function finishOrbit(event, selectOnClick = false) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
const pointerId = event?.pointerId ?? orbitDrag.pointerId;
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;
@@ -961,10 +997,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
callbacks.onSelect?.(hit?.id ?? null);
const startClient = clientPoint(event);
orbitDrag = {
pointerId: event.pointerId,
lastClient: clientPoint(event),
hitId: hit?.id ?? null,
startClient,
lastClient: startClient,
moved: false,
};
try {
canvas.setPointerCapture?.(event.pointerId);
@@ -1023,11 +1062,26 @@ 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;
orbit.yaw += dx * 0.01;
orbit.pitch = clamp(orbit.pitch - dy * 0.01, -Math.PI * 0.46, Math.PI * 0.46);
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();
@@ -1105,6 +1159,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
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();
@@ -1112,11 +1167,13 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
orbit.pitch = clamp(
const nextPitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-Math.PI * 0.46,
Math.PI * 0.46,
-MAX_ORBIT_PITCH,
MAX_ORBIT_PITCH,
);
if (nextPitch !== orbit.pitch) markFreeOrbit();
orbit.pitch = nextPitch;
viewport = null;
schedule();
}
@@ -1159,7 +1216,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishOrbit(event)) return;
if (finishOrbit(event, true)) return;
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
@@ -1202,6 +1259,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return {
render,
setTool,
setViewDirection,
fit,
zoomBy,
focus,
+28 -1
View File
@@ -58,6 +58,19 @@
<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>
@@ -190,6 +203,20 @@
<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">
@@ -240,7 +267,7 @@
</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>
<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>
+47 -1
View File
@@ -42,6 +42,7 @@ const requiredIds = [
"preset-hint",
"generate-preset-button", "array-canvas", "select-tool-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",
@@ -52,7 +53,7 @@ const requiredIds = [
"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-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",
@@ -82,6 +83,12 @@ const beamPatternViews = Object.fromEntries(BEAM_PATTERN_CUTS.map((cut) => [
]));
const beamPatternSurfaceView = createReceiveBeamPatternSurfaceView(
elements["beam-pattern-surface"],
{
onViewDirectionChange(direction) {
elements["beam-pattern-axis-view"].value = direction;
document.documentElement.dataset.beamPatternViewAxis = direction;
},
},
);
const splitPane = createSplitPane({
@@ -354,6 +361,29 @@ 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 syncArrayViewDirection(direction) {
const resolved = Object.hasOwn(VIEW_DIRECTION_LABELS, direction) ? direction : "iso";
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";
}
}
function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden");
}
@@ -382,6 +412,7 @@ function setArrayViewMode(mode, options = {}) {
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"];
@@ -403,6 +434,7 @@ function setArrayViewMode(mode, options = {}) {
elements["canvas-front-badge"].textContent = resolved === "3d"
? "+Z FRONT · 초록 축"
: "+Z FRONT · 화면 바깥쪽";
if (resolved === "3d") syncArrayViewDirection(elements["array-axis-view"].value);
elements["array-canvas"].setAttribute(
"aria-label",
resolved === "3d"
@@ -567,6 +599,9 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
onToolChange(tool) {
setTool(tool, { userInitiated: true });
},
onViewDirectionChange(direction) {
syncArrayViewDirection(direction);
},
onFit() {
canvasView.fit();
},
@@ -1371,6 +1406,11 @@ 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 시점 · " + VIEW_DIRECTION_LABELS[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));
@@ -1399,6 +1439,9 @@ 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();
@@ -1615,6 +1658,9 @@ window.addEventListener("unload", () => {
elements["snap-enabled"].checked = state.snapEnabled;
setNumber(elements["snap-step"], state.snapStepM * 1000);
syncArrayViewDirection("iso");
elements["beam-pattern-axis-view"].value = "iso";
document.documentElement.dataset.beamPatternViewAxis = "iso";
setArrayViewMode("2d", { announce: false });
setBeamPatternViewMode("cuts", { render: false });
syncBeamPatternFrequency();
+36
View File
@@ -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 };
}
+64
View File
@@ -1030,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;
@@ -1429,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);
}
@@ -1488,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%;
}
@@ -1504,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));
}