partforge 0.133.2 → 0.133.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.133.2",
3
+ "version": "0.133.3",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,77 @@
1
+ // src/framework/stale-touch-guard.js
2
+ // Keeps the camera controls from holding onto a finger that has already left
3
+ // the screen.
4
+ //
5
+ // OrbitControls tracks every pointer that goes down on the canvas and forgets
6
+ // one only on its pointerup or pointercancel. When the page is embedded in a
7
+ // host that runs its own gestures (a chat app's WKWebView, which scrolls the
8
+ // conversation on a vertical drag), the host can take a drag over and the page
9
+ // never hears that finger lift. The controls then keep it as down forever: the
10
+ // next one-finger drag reads as a two-finger pinch (it zooms), and with two
11
+ // stuck, no drag moves the camera at all.
12
+ //
13
+ // Two measures, both on the canvas's document and both passive:
14
+ //
15
+ // 1. Do-nothing touchstart/touchend/touchcancel listeners. WebKit on iOS hands
16
+ // a touch to the page only where the page listens for TOUCH events, and the
17
+ // controls listen for pointer events alone, so without these the host's
18
+ // gesture recognizers could claim a drag the page itself was handling.
19
+ //
20
+ // 2. Stale fingers are cancelled on arrival. A touch pointerdown marked
21
+ // isPrimary is the first finger of a brand-new gesture, so no other touch
22
+ // can really be down; every touch id still recorded as down on the canvas is
23
+ // sent the pointercancel it missed, from a capture listener on the document
24
+ // (which runs before the controls' own listener on the canvas). A second
25
+ // finger of the same gesture is never primary, so pinch and two-finger pan
26
+ // are untouched.
27
+ //
28
+ // The controls release pointer capture for the id they remove, and that call
29
+ // throws for an id the browser has already forgotten, aborting their cleanup
30
+ // half way (the move listeners and the gesture state would stay live). So the
31
+ // canvas's releasePointerCapture is wrapped to ignore that one failure.
32
+ const TOUCH_TYPES = ["touchstart", "touchend", "touchcancel"];
33
+
34
+ export function guardStaleTouches(element) {
35
+ const doc = element.ownerDocument;
36
+ if (!doc) return () => {};
37
+ const noop = () => {};
38
+ const touchesDown = new Set();
39
+
40
+ const release = element.releasePointerCapture;
41
+ if (typeof release === "function") {
42
+ element.releasePointerCapture = function releaseIfHeld(pointerId) {
43
+ try {
44
+ release.call(this, pointerId);
45
+ } catch {
46
+ // Already released: the finger is gone, which is all the caller wanted.
47
+ }
48
+ };
49
+ }
50
+
51
+ const onDown = (event) => {
52
+ if (event.pointerType !== "touch") return;
53
+ if (event.isPrimary && touchesDown.size) {
54
+ for (const pointerId of [...touchesDown]) {
55
+ if (pointerId === event.pointerId) continue;
56
+ touchesDown.delete(pointerId);
57
+ element.dispatchEvent(new PointerEvent("pointercancel", { pointerId, pointerType: "touch", bubbles: true }));
58
+ }
59
+ }
60
+ if (event.target === element) touchesDown.add(event.pointerId);
61
+ };
62
+ const onEnd = (event) => { touchesDown.delete(event.pointerId); };
63
+
64
+ for (const type of TOUCH_TYPES) doc.addEventListener(type, noop, { capture: true, passive: true });
65
+ doc.addEventListener("pointerdown", onDown, { capture: true, passive: true });
66
+ doc.addEventListener("pointerup", onEnd, { capture: true, passive: true });
67
+ doc.addEventListener("pointercancel", onEnd, { capture: true, passive: true });
68
+
69
+ return function dispose() {
70
+ for (const type of TOUCH_TYPES) doc.removeEventListener(type, noop, { capture: true });
71
+ doc.removeEventListener("pointerdown", onDown, { capture: true });
72
+ doc.removeEventListener("pointerup", onEnd, { capture: true });
73
+ doc.removeEventListener("pointercancel", onEnd, { capture: true });
74
+ if (typeof release === "function") element.releasePointerCapture = release;
75
+ touchesDown.clear();
76
+ };
77
+ }
@@ -1,5 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
+ import { guardStaleTouches } from "./stale-touch-guard.js";
3
4
  import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
4
5
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
6
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
@@ -231,6 +232,9 @@ export function createViewer(container, part) {
231
232
 
232
233
  const controls = new OrbitControls(activeCamera, renderer.domElement);
233
234
  controls.enableDamping = true;
235
+ // A finger a host gesture swallowed would otherwise stay "down" in the
236
+ // controls forever (every later drag zooms) — see stale-touch-guard.js.
237
+ const disposeTouchGuard = guardStaleTouches(renderer.domElement);
234
238
 
235
239
  // --- render on demand -------------------------------------------------------
236
240
  // The loop ticks every animation frame (controls, tweens, frame listeners —
@@ -2608,6 +2612,7 @@ export function createViewer(container, part) {
2608
2612
  canonicalCaptureHidden.clear();
2609
2613
  camTween.cancel();
2610
2614
  controls.dispose();
2615
+ disposeTouchGuard();
2611
2616
  for (const t of flashTimers) clearTimeout(t);
2612
2617
  flashTimers.clear();
2613
2618
  // A dot whose timer was just cancelled — or one held indefinitely — still