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>
431 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|