Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
238 lines
6.8 KiB
JavaScript
238 lines
6.8 KiB
JavaScript
import { cameraBasis, clamp, clampZoom, dot } from "./math.js";
|
||
|
||
export const axes = [
|
||
{
|
||
name: "X",
|
||
vector: [1, 0, 0],
|
||
yaw: Math.PI / 2,
|
||
pitch: 0,
|
||
color: "#ed5265",
|
||
},
|
||
{
|
||
name: "Y",
|
||
vector: [0, 1, 0],
|
||
yaw: 0,
|
||
pitch: Math.PI / 2,
|
||
color: "#91be38",
|
||
},
|
||
{ name: "Z", vector: [0, 0, 1], yaw: 0, pitch: 0, color: "#438cdd" },
|
||
{
|
||
name: "−X",
|
||
vector: [-1, 0, 0],
|
||
yaw: -Math.PI / 2,
|
||
pitch: 0,
|
||
color: "#ed5265",
|
||
},
|
||
{
|
||
name: "−Y",
|
||
vector: [0, -1, 0],
|
||
yaw: 0,
|
||
pitch: -Math.PI / 2,
|
||
color: "#91be38",
|
||
},
|
||
{ name: "−Z", vector: [0, 0, -1], yaw: Math.PI, pitch: 0, color: "#438cdd" },
|
||
];
|
||
|
||
export function dragView(camera, start, action, dx, dy, rect) {
|
||
if (action === "orbit") {
|
||
camera.yaw = start.yaw - dx * 0.008;
|
||
camera.pitch = clamp(start.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2);
|
||
} else if (action === "pan") {
|
||
const { right, up } = cameraBasis(start.yaw, start.pitch);
|
||
const scale = (2 * start.zoom) / rect.height;
|
||
camera.target = start.target.map(
|
||
(v, k) => v + (-dx * right[k] + dy * up[k]) * scale,
|
||
);
|
||
} else {
|
||
camera.zoom = clampZoom(start.zoom * Math.exp(dy * 0.008), rect);
|
||
}
|
||
}
|
||
|
||
// DOM-only navigation; the camera remains owned by the app, never by the gizmo.
|
||
export class Navigation {
|
||
constructor(root, viewport, host) {
|
||
this.root = root;
|
||
this.viewport = viewport;
|
||
this.host = host;
|
||
this.gizmo = root.querySelector("#orbit-gizmo");
|
||
this.lines = root.querySelectorAll(".axis-line");
|
||
this.buttons = axes.map((axis, i) => {
|
||
const button = document.createElement("button");
|
||
button.className = "axis";
|
||
button.type = "button";
|
||
button.dataset.axis = i;
|
||
button.setAttribute("aria-label", `View from ${axis.name}`);
|
||
button.title = `View from ${axis.name}`;
|
||
button.textContent = i < 3 ? axis.name : "";
|
||
button.style.setProperty("--axis-color", axis.color);
|
||
button.addEventListener("click", (e) => {
|
||
if (e.detail === 0 && !this.busy && host.ready()) this.snap(i);
|
||
});
|
||
this.gizmo.append(button);
|
||
return button;
|
||
});
|
||
root.addEventListener("pointerdown", (e) => this.down(e));
|
||
root.addEventListener("pointermove", (e) => this.move(e));
|
||
root.addEventListener("pointerup", (e) => this.up(e));
|
||
for (const event of ["pointercancel", "lostpointercapture"])
|
||
root.addEventListener(event, (e) => {
|
||
if (this.active?.id === e.pointerId) this.cancel();
|
||
});
|
||
root.addEventListener("contextmenu", (e) => e.preventDefault());
|
||
root.addEventListener("keydown", (e) => this.key(e));
|
||
window.addEventListener("blur", () => this.cancel());
|
||
window.addEventListener("resize", () => this.checkViewport());
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.hidden) this.cancel();
|
||
});
|
||
this.render();
|
||
}
|
||
get busy() {
|
||
return !!this.active;
|
||
}
|
||
down(e) {
|
||
const control = e.target.closest("[data-nav]");
|
||
if (!control || this.busy || !this.host.ready() || e.button !== 0) return;
|
||
e.preventDefault();
|
||
this.host.start();
|
||
const c = this.host.camera;
|
||
this.active = {
|
||
id: e.pointerId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
action: control.dataset.nav,
|
||
axis: e.target.closest("[data-axis]")?.dataset.axis,
|
||
moved: false,
|
||
rect: this.viewport.getBoundingClientRect(),
|
||
start: {
|
||
yaw: c.yaw,
|
||
pitch: c.pitch,
|
||
zoom: c.zoom,
|
||
target: c.target.slice(),
|
||
},
|
||
};
|
||
this.root.setPointerCapture(e.pointerId);
|
||
control.focus({ preventScroll: true });
|
||
this.host.changed();
|
||
}
|
||
checkViewport() {
|
||
if (!this.active) return true;
|
||
const rect = this.viewport.getBoundingClientRect();
|
||
if (
|
||
["left", "top", "width", "height"].some(
|
||
(k) => rect[k] !== this.active.rect[k],
|
||
)
|
||
) {
|
||
this.cancel();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
move(e) {
|
||
if (this.active?.id !== e.pointerId || !this.checkViewport()) return;
|
||
const a = this.active,
|
||
dx = e.clientX - a.x,
|
||
dy = e.clientY - a.y;
|
||
a.moved ||= Math.hypot(dx, dy) >= 4;
|
||
if (a.moved) {
|
||
dragView(this.host.camera, a.start, a.action, dx, dy, a.rect);
|
||
this.host.invalidate();
|
||
}
|
||
}
|
||
up(e) {
|
||
if (this.active?.id !== e.pointerId) return;
|
||
this.move(e);
|
||
const a = this.active;
|
||
if (!a) return;
|
||
this.active = null;
|
||
if (this.root.hasPointerCapture(a.id))
|
||
this.root.releasePointerCapture(a.id);
|
||
if (!a.moved && a.axis !== undefined) this.snap(Number(a.axis));
|
||
this.host.changed();
|
||
}
|
||
cancel() {
|
||
const a = this.active;
|
||
if (!a) return;
|
||
this.active = null;
|
||
Object.assign(this.host.camera, a.start);
|
||
if (this.root.hasPointerCapture(a.id))
|
||
this.root.releasePointerCapture(a.id);
|
||
this.host.invalidate();
|
||
this.host.changed();
|
||
}
|
||
snap(i) {
|
||
// Clicking the already-facing endpoint flips to the opposite view, making
|
||
// the occluded endpoint reachable when two axes project to the same point.
|
||
if (
|
||
dot(
|
||
cameraBasis(this.host.camera.yaw, this.host.camera.pitch).forward,
|
||
axes[i].vector,
|
||
) > 0.999999
|
||
)
|
||
i = (i + 3) % 6;
|
||
const { yaw, pitch } = axes[i];
|
||
Object.assign(this.host.camera, { yaw, pitch });
|
||
this.host.invalidate();
|
||
}
|
||
key(e) {
|
||
if (e.key === "Escape") {
|
||
if (!this.busy) return;
|
||
this.cancel();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
const action = e.target.closest("[data-nav]")?.dataset.nav;
|
||
if (
|
||
!action ||
|
||
![
|
||
"ArrowLeft",
|
||
"ArrowRight",
|
||
"ArrowUp",
|
||
"ArrowDown",
|
||
"+",
|
||
"=",
|
||
"-",
|
||
].includes(e.key)
|
||
)
|
||
return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (this.busy || !this.host.ready()) return;
|
||
const dx = e.key === "ArrowLeft" ? -12 : e.key === "ArrowRight" ? 12 : 0;
|
||
const dy = ["ArrowUp", "+", "="].includes(e.key)
|
||
? -12
|
||
: ["ArrowDown", "-"].includes(e.key)
|
||
? 12
|
||
: 0;
|
||
const c = this.host.camera;
|
||
dragView(
|
||
c,
|
||
{ ...c, target: c.target.slice() },
|
||
action,
|
||
dx,
|
||
dy,
|
||
this.viewport.getBoundingClientRect(),
|
||
);
|
||
this.host.invalidate();
|
||
}
|
||
render() {
|
||
const basis = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||
axes.forEach((axis, i) => {
|
||
const x = 52 + 34 * dot(axis.vector, basis.right),
|
||
y = 52 - 34 * dot(axis.vector, basis.up),
|
||
depth = dot(axis.vector, basis.forward);
|
||
const b = this.buttons[i];
|
||
b.style.left = `${x}px`;
|
||
b.style.top = `${y}px`;
|
||
b.style.zIndex = String(10 + Math.round(depth * 5));
|
||
b.classList.toggle("rear", i >= 3);
|
||
if (i < 3) {
|
||
this.lines[i].setAttribute("x2", x);
|
||
this.lines[i].setAttribute("y2", y);
|
||
this.lines[i].style.stroke = axis.color;
|
||
}
|
||
});
|
||
}
|
||
}
|