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:
Amp
2026-09-28 08:53:50 +00:00
co-authored by heaust
parent eb8aa0e9dc
commit 347b676cf1
25 changed files with 5468 additions and 1 deletions
+237
View File
@@ -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;
}
});
}
}