feat: add live multi-plane and 3d array views
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user