Files
Ampandheaust d279ec4123 Add clay pieces, masking, and topology editing to sculpt
Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-10-01 07:55:48 +00:00

431 lines
13 KiB
JavaScript

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);
const hit = this.host
.engine()
.bvh.raycast(
add(this.world(sample, rect), mul(b.forward, 24)),
mul(b.forward, -1),
);
if (!hit) return null;
const engine = this.host.engine();
const piece = engine.mesh.pieces?.find((p) => p.id === engine.activeId);
return piece &&
hit.face * 3 >= piece.indexStart &&
hit.face * 3 < piece.indexStart + piece.indexCount
? hit
: null;
}
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,
activeId: this.host.engine().activeId,
};
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(),
masks: engine.mesh.masks.slice(),
activeId: engine.activeId,
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, masks, rows, touched } = packet;
if (packet.type === "mask") {
rows.forEach((v, i) => {
engine.mesh.masks[v] = masks[i];
engine.dirty.add(v);
});
for (const v of touched) engine.stroke.touched.add(v);
engine.revision++;
this.host.invalidate();
return;
}
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.
if (engine.stroke.settings.brush !== "mask")
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);
}
}