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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user