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
+406
View File
@@ -0,0 +1,406 @@
import { cameraBasis, add, mul, clamp, clampZoom } from "./math.js";
export class Input {
constructor(viewport, host) {
this.viewport = viewport;
this.host = host;
this.pointers = new Map();
this.active = null;
this.ending = false;
viewport.addEventListener("pointerdown", (e) => this.down(e));
viewport.addEventListener("pointermove", (e) => this.move(e));
viewport.addEventListener("pointerup", (e) => this.up(e));
viewport.addEventListener("pointercancel", (e) => {
const t = this.transaction;
if (
this.pointers.has(e.pointerId) ||
t?.pendingDown?.pointerId === e.pointerId ||
(t && !t.canceling && t.active.id === e.pointerId)
)
this.cancel();
});
viewport.addEventListener("lostpointercapture", (e) => {
if (
this.pointers.has(e.pointerId) ||
this.transaction?.pendingDown?.pointerId === e.pointerId
)
this.cancel();
});
viewport.addEventListener("contextmenu", (e) => e.preventDefault());
viewport.addEventListener(
"wheel",
(e) => {
e.preventDefault();
if (this.active || !host.ready()) return;
host.camera.zoom = clampZoom(
host.camera.zoom * Math.exp(e.deltaY * 0.001),
viewport.getBoundingClientRect(),
);
host.invalidate();
},
{ passive: false },
);
viewport.addEventListener("pointerleave", () => {
if (!this.active) host.cursor(null);
});
window.addEventListener("blur", () => this.cancel());
}
get busy() {
return !!this.active || !!this.transaction;
}
rejectsTouch(e) {
return (
e.pointerType === "touch" &&
(this.host.palmRejection() || this.active?.type === "pen")
);
}
checkViewport(rect = this.viewport.getBoundingClientRect()) {
const owner = this.transaction?.pendingDown ?? this.active;
if (
rect.width <= 0 ||
rect.height <= 0 ||
(owner &&
(owner.canvas !== this.viewport.querySelector("canvas") ||
["left", "top", "width", "height"].some(
(key) => rect[key] !== owner.rect[key],
)))
) {
if (owner) {
this.cancel();
this.host.message(
"Viewport changed · current stroke canceled; completed work preserved",
);
}
return false;
}
return true;
}
sample(e, r = this.active?.rect ?? this.viewport.getBoundingClientRect()) {
return {
x: e.clientX - r.left,
y: e.clientY - r.top,
pressure: e.pointerType === "pen" ? e.pressure : 1,
};
}
world(
sample,
r = this.active?.rect ?? this.viewport.getBoundingClientRect(),
) {
const c = this.host.camera,
b = cameraBasis(c.yaw, c.pitch);
return add(
c.target,
add(
mul(
b.right,
(((sample.x / r.width) * 2 - 1) * c.zoom * r.width) / r.height,
),
mul(b.up, (1 - (sample.y / r.height) * 2) * c.zoom),
),
);
}
pick(sample, rect) {
const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
return this.host
.engine()
.bvh.raycast(
add(this.world(sample, rect), mul(b.forward, 24)),
mul(b.forward, -1),
);
}
down(e) {
if (this.rejectsTouch(e)) return;
const canvas = this.viewport.querySelector("canvas");
if (e.target !== canvas) return;
if (!this.checkViewport()) return;
if (!this.host.ready() || this.ending) return;
if (this.pointers.has(e.pointerId) || this.pointers.size >= 2) return;
e.preventDefault();
this.viewport.focus({ preventScroll: true });
this.viewport.setPointerCapture(e.pointerId);
const rect = this.viewport.getBoundingClientRect();
if (e.pointerType === "pen" && this.active?.type === "touch") {
this.cancel();
if (this.transaction) {
this.transaction.pendingDown = {
pointerId: e.pointerId,
pointerType: e.pointerType,
clientX: e.clientX,
clientY: e.clientY,
pressure: e.pressure,
button: e.button,
shiftKey: e.shiftKey,
altKey: e.altKey,
rect,
canvas,
};
return;
}
}
this.start(e, rect, canvas);
}
start(e, rect, canvas) {
const sample = this.sample(e, rect);
this.pointers.set(e.pointerId, sample);
if (this.pointers.size > 1) {
if (this.active?.mode === "sculpt") {
this.cancelStroke();
}
this.active = {
mode: "gesture",
id: e.pointerId,
type: "touch",
rect,
canvas,
};
this.gesture = this.touchPair();
return;
}
const action =
e.button === 1
? "pan"
: e.button === 2
? "rotate"
: this.host.navigation();
const navigate = action !== "sculpt";
const hit = navigate ? null : this.pick(sample, rect);
this.active = {
id: e.pointerId,
type: e.pointerType,
mode: navigate || !hit ? "orbit" : "sculpt",
action,
start: sample,
last: sample,
hit,
rect,
canvas,
};
if (this.active.mode === "sculpt") {
const settings = { ...this.host.settings };
if (e.shiftKey) settings.brush = "smooth";
if (e.altKey) settings.invert = !settings.invert;
const engine = this.host.engine(),
client = this.host.processor();
engine.begin(settings);
const transaction = {
engine,
client,
active: this.active,
canceling: false,
};
this.transaction = transaction;
client
.begin(
{
positions: engine.mesh.positions.slice(),
settings,
camera: structuredClone(this.host.camera),
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
hit,
sample,
},
(packet) => {
this.checkViewport();
if (this.transaction !== transaction || transaction.canceling)
return;
const { vertices, rows, touched } = packet;
for (let row = 0; row < rows.length; row++) {
const v = rows[row],
i = row * 8,
j = v * 3,
p = engine.mesh.positions,
n = engine.mesh.normals;
p[j] = vertices[i];
p[j + 1] = vertices[i + 1];
p[j + 2] = vertices[i + 2];
n[j] = vertices[i + 4];
n[j + 1] = vertices[i + 5];
n[j + 2] = vertices[i + 6];
engine.dirty.add(v);
}
for (const v of touched) engine.stroke.touched.add(v);
engine.revision++;
this.host.invalidate();
},
)
.then(
(result) => this.settle(transaction, result),
() => {
// StrokeClient reports worker failure once through the renderer's
// error handler; this continuation only settles document ownership.
this.settle(transaction, { canceled: true });
},
);
}
this.host.changed();
}
touchPair() {
const [a, b] = [...this.pointers.values()];
return a && b
? {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
}
: null;
}
pan(dx, dy) {
const c = this.host.camera,
b = cameraBasis(c.yaw, c.pitch),
scale = (2 * c.zoom) / this.active.rect.height;
c.target = add(
c.target,
add(mul(b.right, -dx * scale), mul(b.up, dy * scale)),
);
}
move(e) {
if (this.host.blocked?.()) return;
if (this.rejectsTouch(e)) return;
if (!this.checkViewport()) return;
if (!this.active && e.target.tagName !== "CANVAS") {
this.host.cursor(null);
return;
}
const sample = this.sample(e);
this.host.cursor(sample);
this.host.pressure(e.pointerType === "pen" ? sample.pressure : null);
if (!this.active || (this.ending && this.active.mode === "sculpt")) return;
if (!this.pointers.has(e.pointerId)) return;
this.pointers.set(e.pointerId, sample);
if (this.active.mode === "gesture") {
const next = this.touchPair();
if (next && this.gesture) {
this.pan(next.x - this.gesture.x, next.y - this.gesture.y);
this.host.camera.zoom = clampZoom(
(this.host.camera.zoom * this.gesture.distance) / next.distance,
this.active.rect,
);
this.host.invalidate();
}
this.gesture = next;
return;
}
if (e.pointerId !== this.active.id) return;
if (this.active.mode === "sculpt") {
const samples = e.getCoalescedEvents?.() ?? [];
// Only Grab needs to observe the first effective pressure in the batch;
// writes overwrite a single slot, never enqueue samples.
if (this.host.engine().stroke.settings.brush === "grab")
for (const event of samples.length ? samples : [e])
this.transaction.client.write(this.sample(event));
else this.transaction.client.write(this.sample(samples.at(-1) ?? e));
} else {
const dx = sample.x - this.active.last.x,
dy = sample.y - this.active.last.y;
if (this.active.action === "pan") this.pan(dx, dy);
else if (this.active.action === "zoom")
this.host.camera.zoom = clampZoom(
this.host.camera.zoom * Math.exp(dy * 0.008),
this.active.rect,
);
else {
this.host.camera.yaw -= dx * 0.008;
this.host.camera.pitch = clamp(
this.host.camera.pitch + dy * 0.008,
-Math.PI / 2,
Math.PI / 2,
);
}
this.host.invalidate();
}
if (this.active) this.active.last = sample;
}
up(e) {
if (e.pointerType === "touch" && this.host.palmRejection()) return;
if (this.transaction?.pendingDown?.pointerId === e.pointerId) {
this.transaction.pendingDown = null;
this.release(e.pointerId);
return;
}
if (!this.checkViewport()) return;
this.pointers.delete(e.pointerId);
this.release(e.pointerId);
if (!this.active) return;
if (this.active.mode === "gesture") {
if (!this.pointers.size) {
this.active = null;
this.host.changed();
}
return;
}
if (e.pointerId !== this.active.id) return;
if (this.active.mode === "sculpt") {
this.ending = true;
this.transaction.client.end();
} else {
this.active = null;
this.host.changed();
}
}
settle(transaction, result) {
if (this.transaction !== transaction) return;
// Resize may precede the observer callback or this completion message.
this.checkViewport();
const engine = transaction.engine;
if (result.canceled || transaction.canceling) {
engine.end(true);
if (!transaction.canceling) {
const ids = [...this.pointers.keys()];
this.pointers.clear();
for (const id of ids) this.release(id);
}
this.host.invalidate();
} else {
// Repair main's face-normal cache and picking BVH only at the boundary.
engine.changed([...engine.stroke.touched]);
engine.end();
this.host.committed();
}
const pending = transaction.pendingDown;
this.transaction = null;
if (this.active === transaction.active) this.active = null;
this.ending = false;
if (result.message) this.host.message(result.message);
if (pending) {
if (
this.host.ready() &&
this.host.engine() === engine &&
this.host.processor() === transaction.client &&
this.viewport.hasPointerCapture(pending.pointerId)
)
this.start(pending, pending.rect, pending.canvas);
else this.release(pending.pointerId);
}
this.host.changed();
}
cancelStroke() {
const transaction = this.transaction;
if (!transaction || transaction.canceling) return;
transaction.canceling = true;
this.ending = true;
transaction.client.cancel();
transaction.engine.end(true);
this.host.invalidate();
}
cancel() {
const ids = [...this.pointers.keys()];
if (this.transaction?.pendingDown) {
ids.push(this.transaction.pendingDown.pointerId);
this.transaction.pendingDown = null;
}
this.cancelStroke();
this.pointers.clear();
this.active = null;
for (const id of ids) this.release(id);
this.host.changed();
}
release(id) {
if (this.viewport.hasPointerCapture(id))
this.viewport.releasePointerCapture(id);
}
}