feat: add live multi-plane and 3d array views

This commit is contained in:
2026-08-30 10:35:27 +09:00
parent dda72739a9
commit c289af366c
7 changed files with 1895 additions and 200 deletions
+431 -7
View File
@@ -7,6 +7,7 @@ const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
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"]);
@@ -21,8 +22,10 @@ function finiteNumber(value, fallback = 0) {
function validPosition(position) {
return Array.isArray(position) &&
position.length >= 3 &&
Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1]));
Number.isFinite(Number(position[1])) &&
Number.isFinite(Number(position[2]));
}
function apertureDimensions(arrayDefinition) {
@@ -198,6 +201,89 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
return best;
}
function rotateArrayPosition3d(position, yaw, pitch) {
const x = finiteNumber(position?.[0]);
const y = finiteNumber(position?.[1]);
const z = finiteNumber(position?.[2]);
const cosYaw = Math.cos(yaw);
const sinYaw = Math.sin(yaw);
const cosPitch = Math.cos(pitch);
const sinPitch = Math.sin(pitch);
const horizontal = cosYaw * x - sinYaw * y;
const away = sinYaw * x + cosYaw * y;
return {
horizontal,
vertical: z * cosPitch + away * sinPitch,
depth: z * sinPitch - away * cosPitch,
};
}
/** Project an array [x,y,z] position into a fitted 3D inspection viewport. */
export function projectArrayPosition3d(position, viewport) {
const rotated = rotateArrayPosition3d(position, viewport.yaw, viewport.pitch);
return {
x: viewport.centerPixelX + (rotated.horizontal - viewport.centerHorizontal) * viewport.scale,
y: viewport.centerPixelY - (rotated.vertical - viewport.centerVertical) * viewport.scale,
depth: rotated.depth,
};
}
function fitArrayViewport3d(width, height, model, zoom, orbit) {
const canvasWidth = Math.max(1, finiteNumber(width, 1));
const canvasHeight = Math.max(1, finiteNumber(height, 1));
const margins = resolvedMargins(canvasWidth, canvasHeight, {
left: 32,
right: 32,
top: 45,
bottom: 38,
});
const plot = {
x: margins.left,
y: margins.top,
width: Math.max(1, canvasWidth - margins.left - margins.right),
height: Math.max(1, canvasHeight - margins.top - margins.bottom),
};
const aperture = apertureDimensions(model.arrayDefinition);
const referenceSpan = Math.max(aperture.width, aperture.height, 0.000001);
const points = [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
[0, 0, referenceSpan * 0.72],
];
for (const microphone of model.arrayDefinition.microphones ?? []) {
if (validPosition(microphone?.position)) points.push(microphone.position);
}
const rotated = points.map((position) => rotateArrayPosition3d(position, orbit.yaw, orbit.pitch));
const horizontalValues = rotated.map((point) => point.horizontal);
const verticalValues = rotated.map((point) => point.vertical);
const horizontalMin = Math.min(...horizontalValues);
const horizontalMax = Math.max(...horizontalValues);
const verticalMin = Math.min(...verticalValues);
const verticalMax = Math.max(...verticalValues);
const horizontalSpan = Math.max(0.000001, horizontalMax - horizontalMin);
const verticalSpan = Math.max(0.000001, verticalMax - verticalMin);
const scale = Math.max(
Number.MIN_VALUE,
Math.min(plot.width / (horizontalSpan * 1.22), plot.height / (verticalSpan * 1.22)) * zoom,
);
return {
width: canvasWidth,
height: canvasHeight,
plot,
scale,
zoom,
yaw: orbit.yaw,
pitch: orbit.pitch,
centerHorizontal: (horizontalMin + horizontalMax) / 2,
centerVertical: (verticalMin + verticalMax) / 2,
centerPixelX: plot.x + plot.width / 2,
centerPixelY: plot.y + plot.height / 2,
referenceSpan,
};
}
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
@@ -444,9 +530,233 @@ function drawArray(context, size, model, zoom, drag) {
context.textBaseline = "top";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
context.textBaseline = "top";
context.fillStyle = "rgba(101,242,191,.88)";
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 5, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.arc(viewport.plot.x + 10, viewport.plot.y + 11, 1.8, 0, Math.PI * 2);
context.fill();
context.fillText("+Z FRONT", viewport.plot.x + 20, viewport.plot.y + 5);
context.textAlign = "left";
return viewport;
}
function traceProjectedPositions(context, positions, viewport, close = false) {
context.beginPath();
positions.forEach((position, index) => {
const point = projectArrayPosition3d(position, viewport);
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
if (close) context.closePath();
}
function aperturePerimeter3d(model, sampleCount = 72) {
const aperture = apertureDimensions(model.arrayDefinition);
if (model.apertureShape !== "ellipse") {
return [
[-aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, -aperture.height / 2, 0],
[aperture.width / 2, aperture.height / 2, 0],
[-aperture.width / 2, aperture.height / 2, 0],
];
}
return Array.from({ length: sampleCount }, (_, index) => {
const angle = index / sampleCount * Math.PI * 2;
return [
Math.cos(angle) * aperture.width / 2,
Math.sin(angle) * aperture.height / 2,
0,
];
});
}
function drawProjectedLine(context, start, end, viewport) {
const a = projectArrayPosition3d(start, viewport);
const b = projectArrayPosition3d(end, viewport);
context.beginPath();
context.moveTo(a.x, a.y);
context.lineTo(b.x, b.y);
context.stroke();
return b;
}
function drawArrayPlane3d(context, model, viewport) {
const perimeter = aperturePerimeter3d(model);
traceProjectedPositions(context, perimeter, viewport, true);
context.fillStyle = "rgba(101,242,191,.055)";
context.fill();
context.strokeStyle = "rgba(174,229,210,.72)";
context.lineWidth = 1.5;
context.setLineDash([7, 5]);
context.stroke();
context.setLineDash([]);
const aperture = apertureDimensions(model.arrayDefinition);
context.strokeStyle = "rgba(166,196,186,.14)";
context.lineWidth = 1;
for (const fraction of [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]) {
if (model.apertureShape === "ellipse") {
const bound = Math.sqrt(Math.max(0, 1 - fraction * fraction));
drawProjectedLine(
context,
[fraction * aperture.width / 2, -bound * aperture.height / 2, 0],
[fraction * aperture.width / 2, bound * aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-bound * aperture.width / 2, fraction * aperture.height / 2, 0],
[bound * aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
} else {
drawProjectedLine(
context,
[fraction * aperture.width / 2, -aperture.height / 2, 0],
[fraction * aperture.width / 2, aperture.height / 2, 0],
viewport,
);
drawProjectedLine(
context,
[-aperture.width / 2, fraction * aperture.height / 2, 0],
[aperture.width / 2, fraction * aperture.height / 2, 0],
viewport,
);
}
}
}
function drawAxis3d(context, viewport, endpoint, color, label) {
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 2;
const point = drawProjectedLine(context, [0, 0, 0], endpoint, viewport);
context.beginPath();
context.arc(point.x, point.y, 3, 0, Math.PI * 2);
context.fill();
context.font = "800 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText(label, point.x + 6, point.y);
}
function drawAxes3d(context, model, viewport) {
const aperture = apertureDimensions(model.arrayDefinition);
drawAxis3d(context, viewport, [aperture.width * 0.58, 0, 0], "#ff9e75", "+X");
drawAxis3d(context, viewport, [0, aperture.height * 0.58, 0], "#76b9ff", "+Y");
drawAxis3d(
context,
viewport,
[0, 0, viewport.referenceSpan * 0.72],
"#65f2bf",
"+Z FRONT",
);
}
function projectedMicrophones(model, viewport) {
return (model.arrayDefinition.microphones ?? [])
.filter((microphone) => validPosition(microphone?.position))
.map((microphone, index) => ({
microphone,
index,
point: projectArrayPosition3d(microphone.position, viewport),
}))
.sort((left, right) => left.point.depth - right.point.depth);
}
function drawMicrophones3d(context, model, viewport) {
const microphones = projectedMicrophones(model, viewport);
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
let selected = null;
for (const item of microphones) {
const { microphone, point } = item;
const isSelected = microphone.id === model.selectedMicrophoneId;
const inside = positionInsideAperture(
microphone.position,
model.arrayDefinition.aperture,
model.apertureShape,
);
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = "rgba(239,255,249,.7)";
context.lineWidth = 1;
context.stroke();
if (isSelected) selected = { microphone, point, radius };
}
if (selected) {
context.strokeStyle = "rgba(255,255,255,.94)";
context.lineWidth = 2;
context.beginPath();
context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2);
context.stroke();
drawSelectedLabel(context, selected.microphone, selected.point, viewport);
}
return microphones;
}
function drawArray3d(context, size, model, zoom, orbit) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
drawEmpty(context, size.width, size.height);
return null;
}
const viewport = fitArrayViewport3d(size.width, size.height, model, zoom, orbit);
context.fillStyle = PLOT_BACKGROUND;
context.fillRect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
drawArrayPlane3d(context, model, viewport);
drawAxes3d(context, model, viewport);
viewport.projectedMicrophones = drawMicrophones3d(context, model, viewport);
context.restore();
context.strokeStyle = "rgba(174,211,199,.28)";
context.lineWidth = 1;
context.strokeRect(
viewport.plot.x + 0.5,
viewport.plot.y + 0.5,
Math.max(0, viewport.plot.width - 1),
Math.max(0, viewport.plot.height - 1),
);
context.font = "700 10px ui-monospace, SFMono-Regular, Consolas, monospace";
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.textAlign = "right";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
return viewport;
}
function hitTestProjectedMicrophone(point, model, viewport, radiusPx = 12) {
let best = null;
for (const item of viewport?.projectedMicrophones ?? projectedMicrophones(model, viewport)) {
const distancePx = Math.hypot(item.point.x - point.x, item.point.y - point.y);
const isCloser = !best || distancePx < best.distancePx - 1e-9;
const isNearerTie = best &&
Math.abs(distancePx - best.distancePx) <= 1e-9 &&
item.point.depth > best.point.depth;
if (distancePx <= radiusPx && (isCloser || isNearerTie)) {
best = { ...item, id: item.microphone.id, distancePx };
}
}
return best;
}
function normalizedTool(tool) {
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
}
@@ -471,6 +781,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let destroyed = false;
let zoom = 1;
let tool = "select";
let viewMode = "2d";
let orbit = { ...DEFAULT_ORBIT };
let orbitDrag = null;
let drag = null;
let pendingAdd = null;
@@ -479,15 +792,23 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (canvas.dataset) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
canvas.dataset.viewMode = viewMode;
canvas.dataset.orbiting = String(Boolean(orbitDrag));
}
if (canvas.style) {
canvas.style.cursor = viewMode === "3d"
? orbitDrag ? "grabbing" : "grab"
: tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
function drawCurrent() {
frameId = 0;
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = drawArray(context, size, model, zoom, drag);
viewport = viewMode === "3d"
? drawArray3d(context, size, model, zoom, orbit)
: drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
@@ -499,20 +820,30 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (viewport) return viewport;
if (!model?.arrayDefinition) return null;
const rect = canvas.getBoundingClientRect();
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
viewport = viewMode === "3d"
? fitArrayViewport3d(rect.width, rect.height, model, zoom, orbit)
: fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
return viewport;
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
const nextViewMode = nextModel?.viewMode === "3d" ? "3d" : "2d";
if (viewMode !== nextViewMode) {
viewMode = nextViewMode;
drag = null;
pendingAdd = null;
orbitDrag = null;
}
tool = normalizedTool(nextModel?.tool ?? tool);
if (viewMode === "3d") tool = "select";
viewport = null;
schedule();
}
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
const resolved = viewMode === "3d" ? "select" : normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
@@ -524,6 +855,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function fit() {
zoom = 1;
if (viewMode === "3d") orbit = { ...DEFAULT_ORBIT };
viewport = null;
schedule();
return zoom;
@@ -586,8 +918,29 @@ export function createArrayCanvas(canvas, callbacks = {}) {
TOUCH_GESTURE_THRESHOLD_PX;
}
function finishOrbit(event) {
if (!orbitDrag || (event?.pointerId !== undefined && event.pointerId !== orbitDrag.pointerId)) {
return false;
}
const pointerId = event?.pointerId ?? orbitDrag.pointerId;
orbitDrag = null;
try {
if (canvas.hasPointerCapture?.(pointerId)) canvas.releasePointerCapture?.(pointerId);
} catch {
// Capture can be released by the browser before cancellation arrives.
}
updateCanvasAffordance();
schedule();
return true;
}
function onTouchStart(event) {
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
if (destroyed || !model?.arrayDefinition) return;
if (viewMode === "3d") {
event.preventDefault();
return;
}
if (tool !== "select") return;
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
const currentViewport = ensureViewport();
if (!touch || !currentViewport) return;
@@ -601,8 +954,27 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
if (drag || pendingAdd || orbitDrag) return;
focus();
if (viewMode === "3d") {
event.preventDefault();
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestProjectedMicrophone(pointerPoint(event), model, currentViewport);
callbacks.onSelect?.(hit?.id ?? null);
orbitDrag = {
pointerId: event.pointerId,
lastClient: clientPoint(event),
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
updateCanvasAffordance();
schedule();
return;
}
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
@@ -649,6 +1021,18 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerMove(event) {
if (orbitDrag?.pointerId === event.pointerId) {
const current = clientPoint(event);
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);
viewport = null;
event.preventDefault();
schedule();
return;
}
if (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return;
@@ -705,6 +1089,39 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (viewMode === "3d") {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === "f" || key === "r") {
event.preventDefault();
fit();
callbacks.onFit?.();
return;
}
if (event.key === "Escape") {
event.preventDefault();
finishOrbit();
return;
}
const orbitStep = event.shiftKey ? 0.18 : 0.06;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
orbit.yaw += event.key === "ArrowLeft" ? -orbitStep : orbitStep;
viewport = null;
schedule();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
orbit.pitch = clamp(
orbit.pitch + (event.key === "ArrowUp" ? orbitStep : -orbitStep),
-Math.PI * 0.46,
Math.PI * 0.46,
);
viewport = null;
schedule();
}
return;
}
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") {
event.preventDefault();
@@ -742,16 +1159,22 @@ export function createArrayCanvas(canvas, callbacks = {}) {
}
function onPointerUp(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishOrbit(event)) return;
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
function onLostPointerCapture(event) {
if (orbitDrag?.pointerId === event.pointerId) {
finishOrbit(event);
return;
}
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
}
@@ -798,6 +1221,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
orbitDrag = null;
drag = null;
pendingAdd = null;
},