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
+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,