trid3d 0.2.0 → 0.3.0

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.
@@ -1,5 +1,7 @@
1
1
  import { worldToCamera, cameraForward, cameraRight, cameraUp } from "../math/transform.js";
2
2
  import { pick, worldPerPixel } from "./helpers.js";
3
+ import { invertMat4, transformDirection } from "../math/matrix.js";
4
+ import { worldMatrix, worldPosition } from "../sceneGraph.js";
3
5
 
4
6
  /**
5
7
  * Click an object to pick it, then drag to move it.
@@ -7,9 +9,14 @@ import { pick, worldPerPixel } from "./helpers.js";
7
9
  * - Drag: moves the object parallel to the screen, following the cursor
8
10
  * - Shift + drag: moves it toward or away from the camera instead
9
11
  *
10
- * Objects are picked with `pick` (screen-space bounding box).
12
+ * Objects are picked with `pick`: by screen-space bounding box, or by their
13
+ * triangles with `exact: true`.
11
14
  * Set `obj.draggable = false` to make an object ignore this tool.
12
15
  *
16
+ * Inside a group, the part clicked moves within its group (it follows the
17
+ * cursor however the group is turned or scaled). With `root: true`, the
18
+ * whole group moves instead (the top-level object of what was clicked).
19
+ *
13
20
  * Add this tool after a camera tool so clicking an object drags it
14
21
  * and clicking empty space still moves the camera.
15
22
  */
@@ -19,6 +26,8 @@ export class DragObject {
19
26
  * @param {number} [opts.button=0] - Mouse button that drags (0 left, 1 middle, 2 right)
20
27
  * @param {(obj:Object) => boolean} [opts.filter] - Which objects can be dragged
21
28
  * (default: those without `draggable: false`)
29
+ * @param {boolean} [opts.exact=false] - Pick by triangles instead of bounding boxes (see `pickHit`)
30
+ * @param {boolean} [opts.root=false] - Drag the top-level object (the whole group) of what was clicked
22
31
  * @param {(obj:Object) => void} [opts.onPick] - Called when an object is picked
23
32
  * @param {(obj:Object) => void} [opts.onMove] - Called each time the object moves
24
33
  * @param {(obj:Object) => void} [opts.onDrop] - Called when the object is released
@@ -26,12 +35,16 @@ export class DragObject {
26
35
  constructor({
27
36
  button = 0,
28
37
  filter = obj => obj.draggable !== false,
38
+ exact = false,
39
+ root = false,
29
40
  onPick,
30
41
  onMove,
31
42
  onDrop,
32
43
  } = {}) {
33
44
  this.button = button;
34
45
  this.filter = filter;
46
+ this.exact = exact;
47
+ this.root = root;
35
48
  this.onPick = onPick;
36
49
  this.onMove = onMove;
37
50
  this.onDrop = onDrop;
@@ -43,8 +56,12 @@ export class DragObject {
43
56
  pointerDown(e, scene) {
44
57
  if (e.button !== this.button) return false;
45
58
 
46
- const obj = pick(scene, e.x, e.y, { filter: this.filter });
59
+ let obj = pick(scene, e.x, e.y, { filter: this.filter, exact: this.exact });
47
60
  if (!obj) return false;
61
+ if (this.root) {
62
+ while (obj.parent) obj = obj.parent;
63
+ if (obj.draggable === false) return false;
64
+ }
48
65
 
49
66
  this.dragging = obj;
50
67
  this.onPick?.(obj);
@@ -56,23 +73,30 @@ export class DragObject {
56
73
  if (!obj) return;
57
74
 
58
75
  const cam = scene.camera;
59
- const depth = worldToCamera(obj.pos, cam).z;
76
+ const depth = worldToCamera(worldPosition(obj), cam).z;
60
77
  const k = worldPerPixel(scene, depth);
61
78
 
79
+ // How far to move in the world
80
+ let move;
62
81
  if (e.shiftKey) {
63
82
  // Drag up pushes the object away, drag down pulls it closer
64
83
  const f = cameraForward(cam);
65
84
  const amount = -e.dy * k;
66
- obj.pos.x += f.x * amount;
67
- obj.pos.y += f.y * amount;
68
- obj.pos.z += f.z * amount;
85
+ move = { x: f.x * amount, y: f.y * amount, z: f.z * amount };
69
86
  } else {
70
87
  const r = cameraRight(cam);
71
88
  const u = cameraUp(cam);
72
- obj.pos.x += (r.x * e.dx - u.x * e.dy) * k;
73
- obj.pos.y += (r.y * e.dx - u.y * e.dy) * k;
74
- obj.pos.z += (r.z * e.dx - u.z * e.dy) * k;
89
+ move = {
90
+ x: (r.x * e.dx - u.x * e.dy) * k,
91
+ y: (r.y * e.dx - u.y * e.dy) * k,
92
+ z: (r.z * e.dx - u.z * e.dy) * k,
93
+ };
75
94
  }
95
+ // Inside a group, `pos` is in the group's space: turn the move into it
96
+ if (obj.parent) move = transformDirection(invertMat4(worldMatrix(obj.parent)), move);
97
+ obj.pos.x += move.x;
98
+ obj.pos.y += move.y;
99
+ obj.pos.z += move.z;
76
100
 
77
101
  this.onMove?.(obj);
78
102
  }
@@ -1,9 +1,14 @@
1
- import { localToWorld, worldToCamera, cameraToScreen, DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
1
+ import { worldToCamera, cameraToScreen, DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint } from "../math/matrix.js";
3
+ import { worldMatrix, worldObjects, ancestors } from "../sceneGraph.js";
4
+ import { edgeSegments, wireEdges } from "../render/draw.js";
2
5
 
3
6
  /**
4
7
  * Screen-space bounding box of an object, in canvas pixels.
5
8
  * Only vertices in front of the camera's near plane are used.
6
9
  *
10
+ * Objects inside groups are measured where they're drawn.
11
+ *
7
12
  * @param {import("../scene.js").Scene} scene - Scene the object is in
8
13
  * @param {Object} obj - Mesh instance (mesh, pos, rot, scale)
9
14
  * @returns {{minX:number, minY:number, maxX:number, maxY:number} | null}
@@ -12,10 +17,11 @@ import { localToWorld, worldToCamera, cameraToScreen, DEFAULT_FOV, DEFAULT_NEAR
12
17
  export function screenBounds(scene, obj) {
13
18
  const { camera, canvas } = scene;
14
19
  const near = camera.near ?? DEFAULT_NEAR;
20
+ const world = worldMatrix(obj);
15
21
  let box = null;
16
22
 
17
23
  for (const v of obj.mesh.vertices) {
18
- const pCam = worldToCamera(localToWorld(v, obj), camera);
24
+ const pCam = worldToCamera(transformPoint(world, v), camera);
19
25
  if (pCam.z < near) continue;
20
26
 
21
27
  const p = cameraToScreen(pCam, camera, canvas.width, canvas.height);
@@ -35,27 +41,37 @@ export function screenBounds(scene, obj) {
35
41
  /**
36
42
  * Finds the object under a canvas pixel.
37
43
  *
38
- * An object is hit when the point is inside its screen-space
39
- * bounding box (plus `padding`). When several objects are hit,
40
- * the one whose center is closest to the camera wins.
44
+ * By default an object is hit when the point is inside its screen-space
45
+ * bounding box (plus `padding`), and when several are hit, the one whose
46
+ * center is closest to the camera wins. That's fast, but it can't tell apart
47
+ * objects that overlap on screen (a sword guard in front of its blade).
48
+ *
49
+ * With `exact: true` it tests the object's actual triangles (see `pickHit`),
50
+ * so the click always matches what's drawn.
51
+ *
52
+ * Objects inside groups are found too: the result is the object with the
53
+ * mesh that was hit (use `pickHit(...).path`, or `obj.parent`, for its groups).
41
54
  *
42
55
  * @param {import("../scene.js").Scene} scene - Scene to search
43
56
  * @param {number} x - Canvas X in pixels
44
57
  * @param {number} y - Canvas Y in pixels
45
58
  * @param {Object} [opts]
46
59
  * @param {(obj:Object) => boolean} [opts.filter] - Only consider objects that pass
47
- * @param {number} [opts.padding=4] - Extra pixels around each box
60
+ * @param {number} [opts.padding=4] - Extra pixels around each box (exact: how close to a line counts, for objects without faces)
61
+ * @param {boolean} [opts.exact=false] - Test triangles instead of bounding boxes
48
62
  * @returns {Object | null} The object, or null if nothing is hit
49
63
  */
50
- export function pick(scene, x, y, { filter = () => true, padding = 4 } = {}) {
64
+ export function pick(scene, x, y, { filter = () => true, padding = 4, exact = false } = {}) {
65
+ if (exact) return pickHit(scene, x, y, { filter, padding })?.object ?? null;
66
+
51
67
  const near = scene.camera.near ?? DEFAULT_NEAR;
52
68
  let best = null;
53
69
  let bestDepth = Infinity;
54
70
 
55
- for (const obj of scene.objects) {
56
- if (!filter(obj)) continue;
71
+ for (const obj of worldObjects(scene)) {
72
+ if (!obj.mesh || !filter(obj)) continue;
57
73
 
58
- const depth = worldToCamera(obj.pos, scene.camera).z;
74
+ const depth = worldToCamera(transformPoint(obj.world, { x: 0, y: 0, z: 0 }), scene.camera).z;
59
75
  if (depth < near || depth >= bestDepth) continue;
60
76
 
61
77
  const box = screenBounds(scene, obj);
@@ -71,6 +87,89 @@ export function pick(scene, x, y, { filter = () => true, padding = 4 } = {}) {
71
87
  return best;
72
88
  }
73
89
 
90
+ /**
91
+ * Exact picking: the nearest object whose drawn surface is under a canvas
92
+ * pixel, with which face was hit and how far it is. Every face is projected
93
+ * with the same math as the renderers and tested triangle by triangle.
94
+ * Objects without faces (wireframes) are hit within `padding` pixels of
95
+ * one of their lines.
96
+ *
97
+ * Faces with a corner behind the camera's near plane are skipped. The cost
98
+ * is one pass over the objects' vertices per call: fine for clicks and hover
99
+ * on models of a few thousand faces.
100
+ *
101
+ * @example
102
+ * canvas.addEventListener("click", (e) => {
103
+ * const hit = pickHit(scene, e.offsetX * canvas.width / canvas.clientWidth, e.offsetY * canvas.height / canvas.clientHeight);
104
+ * if (hit) console.log(hit.object.name, "face", hit.face);
105
+ * });
106
+ *
107
+ * @param {import("../scene.js").Scene} scene - Scene to search
108
+ * @param {number} x - Canvas X in pixels
109
+ * @param {number} y - Canvas Y in pixels
110
+ * @param {Object} [opts]
111
+ * @param {(obj:Object) => boolean} [opts.filter] - Only consider objects that pass
112
+ * @param {number} [opts.padding=4] - How many pixels from a line count as a hit (objects without faces)
113
+ * @param {Object[]} [opts.objects=scene.objects] - Objects to test (with their children)
114
+ * @returns {{ object:Object, face:number, depth:number, path:Object[] } | null}
115
+ * `object` has the mesh that was hit, `face` is the index in its
116
+ * `mesh.faces` (-1 for a line), `depth` the distance in front of the
117
+ * camera, `path` the object then its groups up to the top
118
+ * (`[part, group, ..., root]`); null if nothing is hit
119
+ */
120
+ export function pickHit(scene, x, y, { filter = () => true, padding = 4, objects = scene.objects } = {}) {
121
+ const { camera, canvas } = scene;
122
+ const w = canvas.width, h = canvas.height;
123
+ const near = camera.near ?? DEFAULT_NEAR;
124
+ let best = null;
125
+
126
+ for (const object of worldObjects(objects)) {
127
+ if (!object.mesh?.vertices || !filter(object)) continue;
128
+ const mesh = object.mesh;
129
+ const cam = mesh.vertices.map((v) => worldToCamera(transformPoint(object.world, v), camera));
130
+ const scr = cam.map((p) => (p.z > near ? cameraToScreen(p, camera, w, h) : null));
131
+ const closer = (depth) => !best || depth < best.depth;
132
+
133
+ if (mesh.faces?.length) {
134
+ mesh.faces.forEach((f, face) => {
135
+ // Polygons as triangle fans
136
+ for (let i = 1; i < f.length - 1; i++) {
137
+ const ia = f[0], ib = f[i], ic = f[i + 1];
138
+ const a = scr[ia], b = scr[ib], c = scr[ic];
139
+ if (!a || !b || !c) continue;
140
+ const bary = barycentric(x, y, a, b, c);
141
+ if (!bary) continue;
142
+ // Depth blended by 1/z, like perspective does (exact on tilted faces)
143
+ const depth = 1 / (bary[0] / cam[ia].z + bary[1] / cam[ib].z + bary[2] / cam[ic].z);
144
+ if (closer(depth)) best = { object, face, depth };
145
+ }
146
+ });
147
+ } else {
148
+ for (const [i, j] of edgeSegments(wireEdges(mesh))) {
149
+ const a = scr[i], b = scr[j];
150
+ if (!a || !b) continue;
151
+ const dx = b.x - a.x, dy = b.y - a.y;
152
+ const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy || 1)));
153
+ if (Math.hypot(x - (a.x + t * dx), y - (a.y + t * dy)) > padding) continue;
154
+ const depth = 1 / ((1 - t) / cam[i].z + t / cam[j].z);
155
+ if (closer(depth)) best = { object, face: -1, depth };
156
+ }
157
+ }
158
+ }
159
+ if (best) best.path = ancestors(best.object);
160
+ return best;
161
+ }
162
+
163
+ /** Barycentric weights of (x, y) in triangle abc, or null if outside (or degenerate). */
164
+ function barycentric(x, y, a, b, c) {
165
+ const d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
166
+ if (Math.abs(d) < 1e-9) return null;
167
+ const u = ((b.y - c.y) * (x - c.x) + (c.x - b.x) * (y - c.y)) / d;
168
+ const v = ((c.y - a.y) * (x - c.x) + (a.x - c.x) * (y - c.y)) / d;
169
+ const t = 1 - u - v;
170
+ return u >= 0 && v >= 0 && t >= 0 ? [u, v, t] : null;
171
+ }
172
+
74
173
  /**
75
174
  * How many world units one canvas pixel covers at a given distance
76
175
  * from the camera. Useful to make things follow the cursor exactly.
@@ -14,11 +14,13 @@ export class HoverHighlight {
14
14
  * @param {string} [opts.color="#ffffff"] - Highlight color
15
15
  * @param {(obj:Object) => boolean} [opts.filter] - Which objects can be highlighted
16
16
  * (default: those without `hoverable: false`)
17
+ * @param {boolean} [opts.exact=false] - Pick by triangles instead of bounding boxes (see `pickHit`)
17
18
  * @param {(obj:Object|null) => void} [opts.onChange] - Called when the hovered object changes
18
19
  */
19
- constructor({ color = "#ffffff", filter = obj => obj.hoverable !== false, onChange } = {}) {
20
+ constructor({ color = "#ffffff", filter = obj => obj.hoverable !== false, exact = false, onChange } = {}) {
20
21
  this.color = color;
21
22
  this.filter = filter;
23
+ this.exact = exact;
22
24
  this.onChange = onChange;
23
25
 
24
26
  /** The object under the pointer, or null. */
@@ -28,7 +30,7 @@ export class HoverHighlight {
28
30
  }
29
31
 
30
32
  pointerMove(e, scene) {
31
- this._set(pick(scene, e.x, e.y, { filter: this.filter }));
33
+ this._set(pick(scene, e.x, e.y, { filter: this.filter, exact: this.exact }));
32
34
  }
33
35
 
34
36
  pointerLeave() {
@@ -1,8 +1,10 @@
1
1
  export { ToolManager } from "./manager.js";
2
- export { pick, screenBounds, worldPerPixel } from "./helpers.js";
2
+ export { pick, pickHit, screenBounds, worldPerPixel } from "./helpers.js";
3
3
  export { OrbitCamera } from "./orbitCamera.js";
4
4
  export { DragObject } from "./dragObject.js";
5
5
  export { FlyCamera } from "./flyCamera.js";
6
6
  export { HoverHighlight } from "./hoverHighlight.js";
7
7
  export { FpsMeter } from "./fpsMeter.js";
8
8
  export { ObjUploader } from "./objUploader.js";
9
+ export { ViewGizmo } from "./viewGizmo.js";
10
+ export { TransformGizmo } from "./transformGizmo.js";
@@ -100,6 +100,15 @@ export class OrbitCamera {
100
100
  camera.z = this.target.z - f.z * this.distance;
101
101
  }
102
102
 
103
+ /**
104
+ * Restarts the auto-rotate wait (`autoRotateDelay`), as if the user had
105
+ * just dragged. For other tools that turn the camera, so auto-rotate
106
+ * doesn't fight them.
107
+ */
108
+ holdAutoRotate() {
109
+ this._idle = 0;
110
+ }
111
+
103
112
  pointerDown(e) {
104
113
  const panButton = e.button === 1 || e.button === 2 || (e.button === 0 && e.shiftKey);
105
114
 
@@ -0,0 +1,316 @@
1
+ import { cameraForward, worldToCamera, worldToScreen, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { applyTransformOp, matFromAxisAngle, mulVec } from "../math/rotation.js";
3
+ import { meshBounds } from "../render/meshUtils.js";
4
+ import { transformOpToLocal } from "../math/matrix.js";
5
+ import { worldMatrix, ancestors } from "../sceneGraph.js";
6
+ import { worldPerPixel } from "./helpers.js";
7
+
8
+ const AXES = [
9
+ { dir: { x: 1, y: 0, z: 0 }, color: "#e8605a" },
10
+ { dir: { x: 0, y: 1, z: 0 }, color: "#7cc46b" },
11
+ { dir: { x: 0, y: 0, z: 1 }, color: "#5f9be8" },
12
+ ];
13
+ /** Handle id of the uniform-scale square */
14
+ const UNIFORM = 3;
15
+ /** Points per rotate ring */
16
+ const RING = 64;
17
+
18
+ /**
19
+ * Move, rotate and scale handles on one or more objects, around their
20
+ * shared center (the middle of their bounding box). One tool, three modes:
21
+ *
22
+ * - `"move"`: X, Y, Z arrows; drag one to move along that world axis
23
+ * - `"rotate"`: X, Y, Z rings; drag along a ring to turn around that axis.
24
+ * The ring follows the mouse from any camera angle; where rings cross,
25
+ * the one facing the camera wins
26
+ * - `"scale"`: X, Y, Z handles with square ends to stretch along an axis,
27
+ * and a center square for all axes at once (×2 per 100 px dragged right)
28
+ *
29
+ * Targets can be groups (their children move with them) or objects inside
30
+ * groups: the handles work in world space, and each target is changed in
31
+ * its parent's space, so it follows the mouse however its group is turned.
32
+ *
33
+ * By default it changes the targets' `pos`, `rot` and `scale`. With
34
+ * `apply: false` it only reports what the drag does (a `TransformOp`,
35
+ * applied with `applyTransformOp`) so an app can apply it to its own data.
36
+ *
37
+ * Add it **after** the camera tool: tools added later get clicks first, so
38
+ * grabbing a handle doesn't also orbit. Clicks that miss the handles fall
39
+ * through to the tools below.
40
+ *
41
+ * @example
42
+ * scene.use(new OrbitCamera());
43
+ * const gizmo = scene.use(new TransformGizmo({ mode: "rotate" }));
44
+ * gizmo.targets = [box];
45
+ * addEventListener("keydown", (e) => { if (e.key === "g") gizmo.mode = "move"; });
46
+ */
47
+ export class TransformGizmo {
48
+ /**
49
+ * @param {Object} [opts]
50
+ * @param {"move"|"rotate"|"scale"} [opts.mode="move"]
51
+ * @param {Object[]} [opts.targets=[]] - Objects it edits (change `targets` at any time)
52
+ * @param {number} [opts.size=70] - Handle length in CSS pixels (same on screen at any distance)
53
+ * @param {boolean} [opts.apply=true] - Change the targets' transforms while dragging
54
+ * @param {(targets:Object[]) => void} [opts.onGrab] - A handle was grabbed
55
+ * @param {(op:Object, targets:Object[]) => void} [opts.onChange] - While dragging: the operation since the grab
56
+ * @param {(op:Object, targets:Object[]) => void} [opts.onDrop] - On release: the whole operation
57
+ */
58
+ constructor({ mode = "move", targets = [], size = 70, apply = true, onGrab, onChange, onDrop } = {}) {
59
+ this.mode = mode;
60
+ this.targets = targets;
61
+ this.size = size;
62
+ this.apply = apply;
63
+ this.onGrab = onGrab;
64
+ this.onChange = onChange;
65
+ this.onDrop = onDrop;
66
+ /** Hide the handles (and ignore clicks) without removing the tool. */
67
+ this.visible = true;
68
+ /** True while a handle is being dragged. */
69
+ this.dragging = false;
70
+
71
+ this._handles = []; // from the last draw
72
+ this._hover = -1;
73
+ this._drag = null;
74
+ this._cursor = false; // whether this tool set the canvas cursor
75
+ }
76
+
77
+ /** Middle of the targets' bounding box in world space, or null. */
78
+ center() {
79
+ return this.targets.length ? meshBounds(this.targets)?.center ?? null : null;
80
+ }
81
+
82
+ /** Length of the handles in world units at a point, so they keep the same size on screen. */
83
+ _length(scene, c) {
84
+ const k = scene.canvas.width / (scene.canvas.clientWidth || scene.canvas.width);
85
+ return this.size * k * worldPerPixel(scene, worldToCamera(c, scene.camera).z);
86
+ }
87
+
88
+ draw(ctx, scene) {
89
+ this._handles = [];
90
+ if (!this.visible) return;
91
+ // Rings and scale handles stay where the drag started; arrows follow the move
92
+ const c = this._drag && this.mode !== "move" ? this._drag.center : this.center();
93
+ if (!c) return;
94
+ const { canvas, camera } = scene;
95
+ if (worldToCamera(c, camera).z <= (camera.near ?? DEFAULT_NEAR)) return;
96
+ const k = canvas.width / (canvas.clientWidth || canvas.width);
97
+ const len = this._length(scene, c);
98
+ const scr = (p) => worldToScreen(p, camera, canvas.width, canvas.height);
99
+ const o = scr(c);
100
+
101
+ ctx.save();
102
+ ctx.lineCap = ctx.lineJoin = "round";
103
+ AXES.forEach((a, i) => {
104
+ const active = this._drag ? this._drag.id === i : this._hover === i;
105
+ ctx.strokeStyle = ctx.fillStyle = active ? "#ffffff" : a.color;
106
+ ctx.lineWidth = (active ? 3.5 : 2.5) * k;
107
+ if (this.mode === "rotate") {
108
+ const path = [];
109
+ for (let s = 0; s <= RING; s++) path.push(scr(ringPoint(c, a.dir, len * 0.9, s)));
110
+ stroke(ctx, path);
111
+ const f = cameraForward(camera);
112
+ this._handles.push({ id: i, path, facing: Math.abs(a.dir.x * f.x + a.dir.y * f.y + a.dir.z * f.z) });
113
+ } else {
114
+ const to = scr({ x: c.x + a.dir.x * len, y: c.y + a.dir.y * len, z: c.z + a.dir.z * len });
115
+ stroke(ctx, [o, to]);
116
+ if (this.mode === "move") arrowHead(ctx, o, to, 10 * k);
117
+ else square(ctx, to, 5 * k);
118
+ this._handles.push({ id: i, path: [o, to] });
119
+ }
120
+ });
121
+ if (this.mode === "scale") {
122
+ const active = this._drag ? this._drag.id === UNIFORM : this._hover === UNIFORM;
123
+ ctx.fillStyle = active ? "#ffffff" : "#d0d4dc";
124
+ square(ctx, o, 6 * k);
125
+ this._handles.push({ id: UNIFORM, path: [o] });
126
+ } else {
127
+ ctx.fillStyle = "#ffffff";
128
+ ctx.beginPath();
129
+ ctx.arc(o.x, o.y, 3 * k, 0, Math.PI * 2);
130
+ ctx.fill();
131
+ }
132
+ ctx.restore();
133
+ }
134
+
135
+ /** The handle under a pointer, from the last draw. */
136
+ _hit(e, scene) {
137
+ const k = scene.canvas.width / (scene.canvas.clientWidth || scene.canvas.width);
138
+ let best = null, bestD = 9 * k;
139
+ for (const h of this._handles) {
140
+ let d = h.path.length === 1 ? Math.hypot(e.x - h.path[0].x, e.y - h.path[0].y) - 3 * k : distToPath(e, h.path);
141
+ // Where rings cross, take the one facing the camera (an edge-on ring barely turns)
142
+ if (h.facing !== undefined) d -= 4 * k * h.facing;
143
+ if (d < bestD) {
144
+ bestD = d;
145
+ best = h;
146
+ }
147
+ }
148
+ return best;
149
+ }
150
+
151
+ pointerDown(e, scene) {
152
+ if (!this.visible || !this.targets.length || e.button !== 0) return false;
153
+ const h = this._hit(e, scene);
154
+ const center = this.center();
155
+ if (!h || !center) return false;
156
+ const { canvas, camera } = scene;
157
+ const scr = (p) => worldToScreen(p, camera, canvas.width, canvas.height);
158
+ const cs = scr(center);
159
+ const len = this._length(scene, center);
160
+ const axis = h.id < 3 ? AXES[h.id].dir : { x: 0, y: 0, z: 0 };
161
+ const tip = scr({ x: center.x + axis.x * len, y: center.y + axis.y * len, z: center.z + axis.z * len });
162
+
163
+ // Which way +angle turns the grabbed point on screen depends on the view: measure it
164
+ let turnSign = 1;
165
+ if (this.mode === "rotate" && h.id < 3) {
166
+ const s = nearestIndex(e, h.path);
167
+ const grabbed = h.path[s];
168
+ const world = ringPoint(center, axis, len * 0.9, s);
169
+ const turned = mulVec(matFromAxisAngle(axis, 0.01), { x: world.x - center.x, y: world.y - center.y, z: world.z - center.z });
170
+ const p1 = scr({ x: center.x + turned.x, y: center.y + turned.y, z: center.z + turned.z });
171
+ turnSign = Math.sign(wrap(Math.atan2(p1.y - cs.y, p1.x - cs.x) - Math.atan2(grabbed.y - cs.y, grabbed.x - cs.x))) || 1;
172
+ }
173
+
174
+ this._drag = {
175
+ id: h.id,
176
+ x: e.x,
177
+ y: e.y,
178
+ center,
179
+ centerScreen: cs,
180
+ axisScreen: { x: tip.x - cs.x, y: tip.y - cs.y },
181
+ length: len,
182
+ angle0: Math.atan2(e.y - cs.y, e.x - cs.x),
183
+ turnSign,
184
+ start: this.targets.map((t) => ({ pos: { ...t.pos }, rot: { ...t.rot }, scale: { ...t.scale } })),
185
+ // Parents' world matrices at the grab (ops are turned into each target's parent space)
186
+ parents: this.targets.map((t) => (t.parent ? worldMatrix(t.parent) : null)),
187
+ // A target inside another target already moves with it
188
+ moves: this.targets.map((t) => !ancestors(t).slice(1).some((a) => this.targets.includes(a))),
189
+ op: null,
190
+ };
191
+ this.dragging = true;
192
+ this.onGrab?.(this.targets);
193
+ return true;
194
+ }
195
+
196
+ pointerMove(e, scene) {
197
+ const d = this._drag;
198
+ if (!d) {
199
+ const h = this.visible && this.targets.length ? this._hit(e, scene) : null;
200
+ this._hover = h?.id ?? -1;
201
+ if (h) {
202
+ scene.canvas.style.cursor = "pointer";
203
+ this._cursor = true;
204
+ } else if (this._cursor) {
205
+ scene.canvas.style.cursor = "";
206
+ this._cursor = false;
207
+ }
208
+ return;
209
+ }
210
+ // How far along the grabbed handle the pointer moved, in handle lengths
211
+ const along = () => {
212
+ const s = d.axisScreen;
213
+ return ((e.x - d.x) * s.x + (e.y - d.y) * s.y) / (s.x * s.x + s.y * s.y || 1);
214
+ };
215
+ let op;
216
+ if (this.mode === "move") {
217
+ const a = AXES[d.id].dir, t = along() * d.length;
218
+ op = { mode: "move", delta: { x: a.x * t, y: a.y * t, z: a.z * t } };
219
+ } else if (this.mode === "rotate") {
220
+ const angle = wrap(Math.atan2(e.y - d.centerScreen.y, e.x - d.centerScreen.x) - d.angle0) * d.turnSign;
221
+ op = { mode: "rotate", axis: AXES[d.id].dir, angle, center: d.center };
222
+ } else if (d.id === UNIFORM) {
223
+ // Drag right to grow, left to shrink: ×2 per 100 CSS pixels
224
+ const k = scene.canvas.width / (scene.canvas.clientWidth || scene.canvas.width);
225
+ op = { mode: "scale", axis: null, factor: Math.pow(2, (e.x - d.x) / (100 * k)), center: d.center };
226
+ } else {
227
+ op = { mode: "scale", axis: AXES[d.id].dir, factor: Math.max(0.05, 1 + along()), center: d.center };
228
+ }
229
+ d.op = op;
230
+ if (this.apply) {
231
+ this.targets.forEach((t, i) => {
232
+ if (!d.moves[i]) return;
233
+ const local = d.parents[i] ? transformOpToLocal(op, d.parents[i]) : op;
234
+ Object.assign(t, applyTransformOp(d.start[i], local));
235
+ });
236
+ }
237
+ this.onChange?.(op, this.targets);
238
+ }
239
+
240
+ pointerUp() {
241
+ const d = this._drag;
242
+ this._drag = null;
243
+ this.dragging = false;
244
+ if (d?.op) this.onDrop?.(d.op, this.targets);
245
+ }
246
+
247
+ pointerLeave(scene) {
248
+ this._hover = -1;
249
+ if (this._cursor) scene.canvas.style.cursor = "";
250
+ this._cursor = false;
251
+ }
252
+
253
+ detach(scene) {
254
+ this.pointerLeave(scene);
255
+ this._drag = null;
256
+ this.dragging = false;
257
+ }
258
+ }
259
+
260
+ /** Point s (0..RING) of the ring of radius r around `axis` at center c. */
261
+ function ringPoint(c, axis, r, s) {
262
+ const u = Math.abs(axis.x) > 0.9 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 };
263
+ const v = { x: axis.y * u.z - axis.z * u.y, y: axis.z * u.x - axis.x * u.z, z: axis.x * u.y - axis.y * u.x };
264
+ const t = (s / RING) * Math.PI * 2, cs = Math.cos(t) * r, sn = Math.sin(t) * r;
265
+ return { x: c.x + u.x * cs + v.x * sn, y: c.y + u.y * cs + v.y * sn, z: c.z + u.z * cs + v.z * sn };
266
+ }
267
+
268
+ /** Angle difference in -π..π */
269
+ function wrap(a) {
270
+ return Math.atan2(Math.sin(a), Math.cos(a));
271
+ }
272
+
273
+ function stroke(ctx, path) {
274
+ ctx.beginPath();
275
+ path.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
276
+ ctx.stroke();
277
+ }
278
+
279
+ function arrowHead(ctx, from, to, head) {
280
+ const dx = to.x - from.x, dy = to.y - from.y, len = Math.hypot(dx, dy) || 1;
281
+ const ux = dx / len, uy = dy / len;
282
+ ctx.beginPath();
283
+ ctx.moveTo(to.x + ux * head, to.y + uy * head);
284
+ ctx.lineTo(to.x - uy * head * 0.5, to.y + ux * head * 0.5);
285
+ ctx.lineTo(to.x + uy * head * 0.5, to.y - ux * head * 0.5);
286
+ ctx.closePath();
287
+ ctx.fill();
288
+ }
289
+
290
+ function square(ctx, at, r) {
291
+ ctx.fillRect(at.x - r, at.y - r, r * 2, r * 2);
292
+ }
293
+
294
+ function distToSegment(p, a, b) {
295
+ const dx = b.x - a.x, dy = b.y - a.y;
296
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1)));
297
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
298
+ }
299
+
300
+ function distToPath(p, path) {
301
+ let d = Infinity;
302
+ for (let i = 1; i < path.length; i++) d = Math.min(d, distToSegment(p, path[i - 1], path[i]));
303
+ return d;
304
+ }
305
+
306
+ function nearestIndex(p, path) {
307
+ let best = 0, bestD = Infinity;
308
+ path.forEach((q, i) => {
309
+ const d = Math.hypot(p.x - q.x, p.y - q.y);
310
+ if (d < bestD) {
311
+ bestD = d;
312
+ best = i;
313
+ }
314
+ });
315
+ return best;
316
+ }