Add core-powered web sculpting studio with tablet controls
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>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user