Files
Ampandheaust 347b676cf1 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>
2026-09-28 08:53:50 +00:00

238 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
});
}
}