summaryrefslogtreecommitdiff
path: root/public/js/components/wield.js
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/wield.js
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/wield.js')
-rw-r--r--public/js/components/wield.js629
1 files changed, 629 insertions, 0 deletions
diff --git a/public/js/components/wield.js b/public/js/components/wield.js
new file mode 100644
index 0000000..864ff8d
--- /dev/null
+++ b/public/js/components/wield.js
@@ -0,0 +1,629 @@
+/* global AFRAME, THREE, NAF, CurrentWorld */
+// Picking things up and drawing in the air with them, like the pens in
+// VRChat. A world offers `tools` (see world.js), each lying along its own +x.
+// In VR, squeeze grip near one and it goes into your hand by its grip; let go
+// and it floats where you left it. Pull the trigger while holding it to draw
+// from its tip. With an empty hand, touch a line and pull the trigger to erase
+// it. On desktop, F picks up (or lets go of) the one you're looking at, holding
+// the mouse button draws, and Backspace erases your last line. In a shared room
+// everyone sees who holds what and every line, and newcomers are caught up.
+// A world can also offer `touchables` (the temple's Veil): reach an empty hand
+// to one, a label above your hand says what the trigger will do, and the
+// trigger does it.
+(function () {
+ const GRAB_REACH = 0.1; // hand to tool (m)
+ const LOOK_REACH = 2.2; // desktop: head to tool
+ const LOOK_AIM = 0.35; // desktop: how far off your line of sight it can be
+ const ERASE_REACH = 0.05; // hand to line
+ const TOUCH_REACH = 0.2; // hand to a touchable
+ const STEP = 0.003; // a new point every 3 mm of tip travel
+ const RADIUS = 0.004;
+ const SIDES = 6;
+ const MAX_POINTS = 1024; // per line; a longer stroke carries on in a new line
+ const MAX_LINES = 300;
+ const MAX_TOTAL = 100000; // points in all lines; the oldest lines go first
+ const POSE_MS = 66; // how often a held tool's pose is sent
+ const INK_MS = 100; // how often new points are sent
+ const SYNC_POINTS = 3000; // points per catch-up message
+ const SMOOTH = 0.05; // seconds to ease toward someone else's pose
+
+ // In the WebXR grip space a held object runs along -Z out of the fist and
+ // the palm faces ±X, so the tool's +x goes to -Z and its flat faces the palm.
+ const HOLD = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(
+ new THREE.Vector3(0, 0, -1), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0)));
+ // On desktop it's held low and to the right, its tip toward the middle of your view.
+ const DESK_POS = new THREE.Vector3(0.16, -0.2, -0.3);
+ const DESK_QUAT = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.35, 0.35, 0, 'YXZ')).multiply(HOLD);
+
+ const v1 = new THREE.Vector3();
+ const v2 = new THREE.Vector3();
+ const v3 = new THREE.Vector3();
+ const side = new THREE.Vector3();
+ const up = new THREE.Vector3();
+ const q1 = new THREE.Quaternion();
+
+ const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected();
+ const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000);
+ const validId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 40;
+ const validColor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c);
+ const round = (x) => Math.round(x * 1e4) / 1e4;
+
+ // Distance from p to the segment a–b.
+ function toSegment(p, a, b) {
+ v3.subVectors(b, a);
+ const t = THREE.MathUtils.clamp(v3.dot(v1.subVectors(p, a)) / v3.lengthSq(), 0, 1);
+ return p.distanceTo(v1.copy(a).addScaledVector(v3, t));
+ }
+
+ // Every line has the same triangles, so they share one index.
+ let sharedIndex = null;
+ function lineIndex() {
+ if (sharedIndex) return sharedIndex;
+ const index = new Uint16Array((MAX_POINTS - 1) * SIDES * 6);
+ let k = 0;
+ for (let seg = 0; seg < MAX_POINTS - 1; seg++) {
+ for (let s = 0; s < SIDES; s++) {
+ const a = seg * SIDES + s;
+ const b = seg * SIDES + ((s + 1) % SIDES);
+ index.set([a, b, a + SIDES, b, b + SIDES, a + SIDES], k);
+ k += 6;
+ }
+ }
+ sharedIndex = new THREE.BufferAttribute(index, 1);
+ return sharedIndex;
+ }
+
+ const materials = new Map();
+ function inkMaterial(color) {
+ if (!materials.has(color)) {
+ materials.set(color, new THREE.MeshBasicMaterial({ color, toneMapped: false, fog: false, side: THREE.DoubleSide }));
+ }
+ return materials.get(color);
+ }
+
+ // One stroke: a thin tube that grows a ring at a time.
+ class Line {
+ constructor(id, color) {
+ this.id = id;
+ this.color = color;
+ this.points = []; // x, y, z, …
+ this.count = 0;
+ this.normal = new THREE.Vector3();
+ this.tangent = new THREE.Vector3();
+ const geometry = new THREE.BufferGeometry();
+ this.position = new THREE.BufferAttribute(new Float32Array(MAX_POINTS * SIDES * 3), 3);
+ this.position.setUsage(THREE.DynamicDrawUsage);
+ geometry.setAttribute('position', this.position);
+ geometry.setIndex(lineIndex());
+ geometry.setDrawRange(0, 0);
+ this.mesh = new THREE.Mesh(geometry, inkMaterial(color));
+ this.mesh.frustumCulled = false;
+ }
+
+ get full() {
+ return this.count >= MAX_POINTS;
+ }
+
+ last(out) {
+ const p = this.points;
+ return out.set(p[p.length - 3], p[p.length - 2], p[p.length - 1]);
+ }
+
+ push(x, y, z) {
+ if (this.full) return;
+ const i = this.count;
+ this.points.push(x, y, z);
+ this.count++;
+ if (i === 0) return;
+ const p = this.points;
+ const t = this.tangent.set(x - p[3 * i - 3], y - p[3 * i - 2], z - p[3 * i - 1]);
+ if (t.lengthSq() < 1e-12) t.set(1, 0, 0);
+ t.normalize();
+ // Carry the ring's orientation along the line so the tube doesn't twist.
+ const n = this.normal;
+ if (i > 1) n.addScaledVector(t, -n.dot(t));
+ if (i === 1 || n.lengthSq() < 1e-6) {
+ up.set(0, 1, 0);
+ if (Math.abs(t.y) > 0.9) up.set(1, 0, 0);
+ n.copy(up).addScaledVector(t, -up.dot(t));
+ }
+ n.normalize();
+ if (i === 1) this.ring(0);
+ this.ring(i);
+ this.mesh.geometry.setDrawRange(0, (this.count - 1) * SIDES * 6);
+ this.position.needsUpdate = true;
+ }
+
+ ring(i) {
+ const p = this.points;
+ const b = side.crossVectors(this.tangent, this.normal);
+ const a = this.position.array;
+ for (let s = 0; s < SIDES; s++) {
+ const angle = (s / SIDES) * Math.PI * 2;
+ const c = Math.cos(angle) * RADIUS;
+ const d = Math.sin(angle) * RADIUS;
+ const o = (i * SIDES + s) * 3;
+ a[o] = p[3 * i] + this.normal.x * c + b.x * d;
+ a[o + 1] = p[3 * i + 1] + this.normal.y * c + b.y * d;
+ a[o + 2] = p[3 * i + 2] + this.normal.z * c + b.z * d;
+ }
+ }
+
+ near(pos, reach) {
+ const p = this.points;
+ const r2 = reach * reach;
+ for (let k = 0; k < p.length; k += 3) {
+ const dx = p[k] - pos.x;
+ const dy = p[k + 1] - pos.y;
+ const dz = p[k + 2] - pos.z;
+ if (dx * dx + dy * dy + dz * dz < r2) return true;
+ }
+ return false;
+ }
+
+ dispose() {
+ this.mesh.removeFromParent();
+ this.mesh.geometry.setIndex(null); // the index is shared; keep it
+ this.mesh.geometry.dispose();
+ }
+ }
+
+ function buzz(handEl, strength, ms) {
+ try {
+ const tracked = handEl.components['tracked-controls'];
+ const pad = tracked && tracked.controller && tracked.controller.gamepad;
+ const motor = pad && pad.hapticActuators && pad.hapticActuators[0];
+ if (motor && motor.pulse) Promise.resolve(motor.pulse(strength, ms)).catch(() => {});
+ } catch (e) {
+ // No haptics on this controller.
+ }
+ }
+
+ AFRAME.registerComponent('wield', {
+ schema: {
+ head: { type: 'selector', default: '#player' },
+ leftHand: { type: 'selector', default: '#left-hand' },
+ rightHand: { type: 'selector', default: '#right-hand' },
+ },
+
+ init() {
+ this.tools = null; // the world's, once it has loaded
+ this.touchables = [];
+ this.lines = new Map(); // id → Line, oldest first
+ this.erased = new Set(); // ids of erased lines, so a late catch-up doesn't bring them back
+ this.total = 0;
+ this.prefix = Math.random().toString(36).slice(2, 8);
+ this.serial = 0;
+ this.lastPose = 0;
+ this.group = new THREE.Group();
+ this.el.sceneEl.object3D.add(this.group);
+
+ const hand = (el, vr) => ({ el, vr, tool: null, line: null, sent: 0, sentAt: 0, trigger: false, erasing: false, touching: null });
+ this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)];
+ this.desk = hand(this.data.head, false);
+ for (const h of this.hands) h.prompt = this.makePrompt();
+
+ this.listeners = [];
+ const on = (target, type, fn) => {
+ target.addEventListener(type, fn);
+ this.listeners.push([target, type, fn]);
+ };
+
+ for (const h of this.hands) {
+ on(h.el, 'gripdown', () => this.grabNear(h));
+ on(h.el, 'gripup', () => this.release(h));
+ on(h.el, 'triggerdown', () => this.pull(h, true));
+ on(h.el, 'triggerup', () => this.pull(h, false));
+ on(h.el, 'controllerdisconnected', () => this.release(h));
+ }
+ on(this.el.sceneEl, 'enter-vr', () => this.release(this.desk));
+ on(this.el.sceneEl, 'exit-vr', () => this.hands.forEach((h) => this.release(h)));
+
+ const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea');
+ on(window, 'keydown', (evt) => {
+ if (evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return;
+ if (evt.code === 'KeyF') {
+ if (this.desk.tool) this.release(this.desk);
+ else this.grabLooked();
+ }
+ if (evt.code === 'Backspace') {
+ evt.preventDefault();
+ this.eraseLast();
+ }
+ });
+ on(window, 'mousedown', (evt) => {
+ if (evt.button === 0 && document.pointerLockElement === this.el.sceneEl.canvas) this.pull(this.desk, true);
+ });
+ on(window, 'mouseup', (evt) => {
+ if (evt.button === 0) this.pull(this.desk, false);
+ });
+ on(window, 'blur', () => this.pull(this.desk, false));
+
+ if (typeof NAF !== 'undefined' && NAF.connection) {
+ NAF.connection.subscribeToDataChannel('wield', (from, type, msg) => this.onPose(msg));
+ NAF.connection.subscribeToDataChannel('ink', (from, type, msg) => this.onInk(msg));
+ on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId));
+ on(document.body, 'clientDisconnected', (evt) => {
+ for (const tool of this.tools || []) if (tool.by === evt.detail.clientId) tool.by = null;
+ });
+ }
+ },
+
+ remove() {
+ for (const [target, type, fn] of this.listeners) target.removeEventListener(type, fn);
+ this.group.removeFromParent();
+ },
+
+ // A label that floats above your hand while it's near something to touch.
+ makePrompt() {
+ const el = document.createElement('a-entity');
+ el.setAttribute('geometry', { primitive: 'plane', width: 0.26, height: 0.045 });
+ el.setAttribute('material', { shader: 'flat', color: '#000000', opacity: 0.6, transparent: true });
+ el.setAttribute('text', { value: '', align: 'center', width: 0.4, wrapCount: 28, color: '#ffe2c4' });
+ el.object3D.visible = false;
+ this.el.sceneEl.appendChild(el);
+ return el;
+ },
+
+ // The world is added after the rig, so look for its tools until they're there.
+ findTools() {
+ const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null;
+ if (!world) return;
+ this.touchables = world.touchables || [];
+ this.tools = (world.tools || []).map((spec) => {
+ const mark = new THREE.Mesh(new THREE.SphereGeometry(0.005, 10, 8), inkMaterial(spec.ink));
+ mark.position.copy(spec.tip);
+ mark.visible = false;
+ spec.object.add(mark);
+ return { ...spec, mark, holder: null, by: null, v: 0, target: null };
+ });
+ },
+
+ // ---- holding -------------------------------------------------------------
+
+ myId() {
+ return online() ? NAF.clientId : 'me';
+ },
+
+ // Who holds it now: you, someone else, or no one (null).
+ holderId(tool) {
+ return tool.holder ? this.myId() : tool.by;
+ },
+
+ worldEnds(tool) {
+ tool.object.updateWorldMatrix(true, false);
+ return [tool.object.localToWorld(new THREE.Vector3().copy(tool.butt)), tool.object.localToWorld(new THREE.Vector3().copy(tool.tip))];
+ },
+
+ grabNear(hand) {
+ if (!this.tools || hand.tool) return;
+ const at = hand.el.object3D.getWorldPosition(new THREE.Vector3());
+ let best = null;
+ let bestDistance = GRAB_REACH;
+ for (const tool of this.tools) {
+ if (tool.by) continue; // someone else has it
+ const [a, b] = this.worldEnds(tool);
+ const d = toSegment(at, a, b);
+ if (d < bestDistance) {
+ best = tool;
+ bestDistance = d;
+ }
+ }
+ if (best) this.grab(hand, best);
+ },
+
+ // Desktop: the tool nearest your line of sight, within reach.
+ grabLooked() {
+ if (!this.tools) return;
+ const head = this.data.head.object3D;
+ const eye = head.getWorldPosition(new THREE.Vector3());
+ const ahead = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1));
+ let best = null;
+ let bestAim = LOOK_AIM;
+ let heldNearby = null;
+ for (const tool of this.tools) {
+ const [a, b] = this.worldEnds(tool);
+ for (let i = 0; i <= 8; i++) {
+ const p = v2.lerpVectors(a, b, i / 8).sub(eye);
+ const along = p.dot(ahead);
+ if (along < 0 || p.length() > LOOK_REACH) continue;
+ const aim = p.addScaledVector(ahead, -along).length();
+ if (aim >= bestAim) continue;
+ if (tool.by) {
+ heldNearby = tool;
+ continue;
+ }
+ best = tool;
+ bestAim = aim;
+ }
+ }
+ if (best) this.grab(this.desk, best);
+ else this.el.emit('wield-failed', { reason: heldNearby ? 'held' : 'far', label: heldNearby && heldNearby.label }, false);
+ },
+
+ grab(hand, tool) {
+ if (tool.holder && tool.holder !== hand) {
+ // From your other hand.
+ this.stopDrawing(tool.holder);
+ tool.holder.tool = null;
+ }
+ const obj = tool.object;
+ hand.el.object3D.add(obj);
+ obj.quaternion.copy(hand.vr ? HOLD : DESK_QUAT);
+ obj.position.copy(tool.grip).applyQuaternion(obj.quaternion).negate();
+ if (!hand.vr) obj.position.add(DESK_POS);
+ tool.holder = hand;
+ tool.by = null;
+ tool.target = null;
+ hand.tool = tool;
+ tool.v++;
+ this.sendPose(tool, true);
+ if (hand.vr) buzz(hand.el, 0.4, 40);
+ hand.erasing = false;
+ if (hand.trigger) this.startLine(hand); // trigger already held
+ },
+
+ // Lets go: it stays exactly where it was, floating.
+ release(hand) {
+ const tool = hand.tool;
+ if (!tool) return;
+ this.stopDrawing(hand);
+ this.el.sceneEl.object3D.attach(tool.object);
+ tool.holder = null;
+ hand.tool = null;
+ tool.v++;
+ this.sendPose(tool, true);
+ },
+
+ sendPose(tool, guaranteed, to) {
+ if (!online()) return;
+ const obj = tool.object;
+ obj.updateWorldMatrix(true, false);
+ const p = obj.getWorldPosition(v1);
+ const q = obj.getWorldQuaternion(q1);
+ const msg = {
+ t: tool.name, by: this.holderId(tool), v: tool.v,
+ p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)],
+ };
+ if (to) NAF.connection.sendDataGuaranteed(to, 'wield', msg);
+ else if (guaranteed) NAF.connection.broadcastDataGuaranteed('wield', msg);
+ else NAF.connection.broadcastData('wield', msg);
+ },
+
+ onPose(msg) {
+ if (!this.tools) this.findTools();
+ if (!this.tools || !msg) return;
+ const tool = this.tools.find((t) => t.name === msg.t);
+ if (!tool || !Number.isInteger(msg.v) || !finite(msg.p, 3) || !finite(msg.q, 4)) return;
+ const by = typeof msg.by === 'string' ? msg.by : null;
+ const current = this.holderId(tool);
+ if (msg.v < tool.v) return;
+ // Two people grabbed it at once: the lower id keeps it, the same for everyone.
+ if (msg.v === tool.v && by !== current && (by || '￿') > (current || '￿')) return;
+ tool.v = msg.v;
+ if (tool.holder && by !== this.myId()) {
+ const hand = tool.holder;
+ this.stopDrawing(hand);
+ hand.tool = null;
+ tool.holder = null;
+ }
+ if (tool.holder) return;
+ tool.by = by;
+ if (tool.object.parent !== this.el.sceneEl.object3D) this.el.sceneEl.object3D.attach(tool.object);
+ const q = new THREE.Quaternion().fromArray(msg.q);
+ if (q.lengthSq() < 1e-6) return;
+ tool.target = { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() };
+ },
+
+ // ---- drawing and erasing -------------------------------------------------
+
+ // Trigger (or mouse button) down or up: draw with what you hold, use what
+ // an empty hand is touching, or else erase with it.
+ pull(hand, down) {
+ if (hand.trigger === down) return;
+ hand.trigger = down;
+ if (!down) {
+ hand.erasing = false;
+ this.stopDrawing(hand);
+ } else if (hand.tool) {
+ this.startLine(hand);
+ } else if (hand.touching) {
+ hand.touching.use();
+ buzz(hand.el, 0.5, 60);
+ } else {
+ hand.erasing = hand.vr;
+ }
+ },
+
+ // Which touchable an empty hand is near, and its label above the hand.
+ feel(hand) {
+ let near = null;
+ if (!hand.tool && this.el.sceneEl.is('vr-mode') && hand.el.object3D.visible) {
+ const at = hand.el.object3D.getWorldPosition(v1);
+ let best = TOUCH_REACH;
+ for (const t of this.touchables) {
+ const d = t.distance(at);
+ if (d < best) {
+ near = t;
+ best = d;
+ }
+ }
+ }
+ if (near && near !== hand.touching) buzz(hand.el, 0.15, 20);
+ hand.touching = near;
+ const prompt = hand.prompt.object3D;
+ prompt.visible = Boolean(near);
+ if (!near) return;
+ const text = `Trigger: ${near.label()}`;
+ if (hand.prompt.getAttribute('text').value !== text) hand.prompt.setAttribute('text', 'value', text);
+ prompt.position.copy(v1).y += 0.1;
+ prompt.lookAt(this.data.head.object3D.getWorldPosition(v2));
+ },
+
+ startLine(hand) {
+ const line = this.addLine(`${this.prefix}.${this.serial++}`, hand.tool.ink);
+ hand.line = line;
+ hand.sent = 0;
+ hand.sentAt = 0;
+ },
+
+ stopDrawing(hand) {
+ const line = hand.line;
+ if (!line) return;
+ this.sendInk(hand);
+ hand.line = null;
+ if (line.count < 2) this.erase([line.id], hand.sent > 0);
+ },
+
+ addLine(id, color) {
+ const line = new Line(id, color);
+ this.lines.set(id, line);
+ this.group.add(line.mesh);
+ return line;
+ },
+
+ addPoints(line, x, y, z) {
+ line.push(x, y, z);
+ this.total++;
+ },
+
+ // The oldest lines go once there are too many, here and for everyone else
+ // by the same rule.
+ trim() {
+ for (const line of this.lines.values()) {
+ if (this.lines.size <= MAX_LINES && this.total <= MAX_TOTAL) return;
+ if (this.hands.concat(this.desk).some((h) => h.line === line)) continue;
+ this.erase([line.id], false);
+ }
+ },
+
+ erase(ids, share) {
+ for (const id of ids) {
+ this.erased.add(id);
+ const line = this.lines.get(id);
+ if (!line) continue;
+ this.total -= line.count;
+ line.dispose();
+ this.lines.delete(id);
+ }
+ if (this.erased.size > 5000) this.erased.delete(this.erased.values().next().value);
+ if (share && online()) NAF.connection.broadcastDataGuaranteed('ink', { op: 'erase', ids });
+ },
+
+ eraseLast() {
+ const mine = [...this.lines.values()].reverse().find((l) => l.id.startsWith(`${this.prefix}.`) && l !== this.desk.line);
+ if (mine) this.erase([mine.id], true);
+ },
+
+ sendInk(hand) {
+ const line = hand.line;
+ if (!line || hand.sent >= line.count) return;
+ if (online()) {
+ const pts = line.points.slice(hand.sent * 3).map((x) => Math.round(x * 1000));
+ NAF.connection.broadcastDataGuaranteed('ink', { op: 'add', id: line.id, c: line.color, n: hand.sent, pts });
+ }
+ hand.sent = line.count;
+ },
+
+ // Points (in mm) for a line, from someone else; `n` is how many it already had.
+ takePoints(id, color, n, pts) {
+ if (!validId(id) || this.erased.has(id) || !validColor(color) || !Number.isInteger(n) || n < 0) return;
+ if (!Array.isArray(pts) || pts.length % 3 || pts.length > MAX_POINTS * 3 || !pts.every((x) => Number.isFinite(x) && Math.abs(x) < 1e6)) return;
+ const line = this.lines.get(id) || this.addLine(id, color);
+ for (let k = Math.max(0, line.count - n) * 3; k < pts.length && !line.full; k += 3) {
+ this.addPoints(line, pts[k] / 1000, pts[k + 1] / 1000, pts[k + 2] / 1000);
+ }
+ this.trim();
+ },
+
+ onInk(msg) {
+ if (!msg) return;
+ if (msg.op === 'add') this.takePoints(msg.id, msg.c, msg.n, msg.pts);
+ if (msg.op === 'erase' && Array.isArray(msg.ids)) this.erase(msg.ids.filter(validId).slice(0, 50), false);
+ if (msg.op === 'sync') {
+ if (Array.isArray(msg.erased)) this.erase(msg.erased.filter(validId).slice(0, 1000), false);
+ if (Array.isArray(msg.lines)) for (const l of msg.lines.slice(0, MAX_LINES)) if (l) this.takePoints(l.id, l.c, 0, l.pts);
+ }
+ },
+
+ // Tells someone who just arrived where the tools are and what's drawn.
+ // Everyone here does this; lines they already have are skipped.
+ catchUp(clientId) {
+ if (!online()) return;
+ for (const tool of this.tools || []) if (tool.v > 0) this.sendPose(tool, true, clientId);
+ let batch = [];
+ let size = 0;
+ const send = (erased) => {
+ NAF.connection.sendDataGuaranteed(clientId, 'ink', { op: 'sync', lines: batch, erased });
+ batch = [];
+ size = 0;
+ };
+ for (const line of this.lines.values()) {
+ batch.push({ id: line.id, c: line.color, pts: line.points.map((x) => Math.round(x * 1000)) });
+ size += line.count;
+ if (size >= SYNC_POINTS) send([]);
+ }
+ send([...this.erased].slice(-1000));
+ },
+
+ // ---- every frame -----------------------------------------------------------
+
+ tick(time, delta) {
+ if (!this.tools) {
+ this.findTools();
+ if (!this.tools) return;
+ }
+ const dt = Math.min(delta, 100) / 1000;
+ const ease = 1 - Math.exp(-dt / SMOOTH);
+
+ for (const tool of this.tools) {
+ tool.mark.visible = Boolean(tool.holder || tool.by);
+ if (!tool.target || tool.holder) continue;
+ const obj = tool.object;
+ obj.position.lerp(tool.target.p, ease);
+ obj.quaternion.slerp(tool.target.q, ease);
+ if (!tool.by && obj.position.distanceToSquared(tool.target.p) < 1e-8) tool.target = null;
+ }
+
+ for (const hand of this.hands) this.feel(hand);
+ for (const hand of this.hands.concat(this.desk)) {
+ if (hand.line) this.draw(hand, time);
+ if (hand.erasing) {
+ const at = hand.el.object3D.getWorldPosition(v1);
+ const hit = [...this.lines.values()].filter((l) => l.near(at, ERASE_REACH)).map((l) => l.id);
+ if (hit.length) {
+ this.erase(hit, true);
+ buzz(hand.el, 0.25, 25);
+ }
+ }
+ }
+
+ if (time - this.lastPose >= POSE_MS) {
+ this.lastPose = time;
+ for (const tool of this.tools) if (tool.holder) this.sendPose(tool, false);
+ }
+ },
+
+ draw(hand, time) {
+ const tool = hand.tool;
+ tool.object.updateWorldMatrix(true, false);
+ const tip = tool.object.localToWorld(v2.copy(tool.tip));
+ let line = hand.line;
+ if (line.count && line.last(v3).distanceTo(tip) < STEP) return;
+ if (line.full) {
+ // Carry on in a new line from where this one ends.
+ const end = line.last(new THREE.Vector3());
+ this.sendInk(hand);
+ this.startLine(hand);
+ line = hand.line;
+ this.addPoints(line, end.x, end.y, end.z);
+ }
+ this.addPoints(line, tip.x, tip.y, tip.z);
+ this.trim();
+ if (time - hand.sentAt >= INK_MS) {
+ hand.sentAt = time;
+ this.sendInk(hand);
+ }
+ },
+ });
+})();