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.
- package/README.md +8 -7
- package/package.json +1 -1
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +107 -21
- package/trid/index.d.ts +417 -8
- package/trid/index.js +19 -3
- package/trid/loaders/mtl.js +63 -0
- package/trid/loaders/obj.js +58 -6
- package/trid/loaders/texture.js +47 -6
- package/trid/math/matrix.js +200 -0
- package/trid/math/noise.js +162 -0
- package/trid/math/rotation.js +152 -0
- package/trid/render/canvasRenderer.js +40 -23
- package/trid/render/draw.js +6 -2
- package/trid/render/meshUtils.js +257 -0
- package/trid/render/proceduralTextures.js +211 -0
- package/trid/render/shapes.js +95 -27
- package/trid/render/webglRenderer.js +98 -27
- package/trid/scene.js +20 -9
- package/trid/sceneGraph.js +131 -0
- package/trid/tools/dragObject.js +33 -9
- package/trid/tools/helpers.js +109 -10
- package/trid/tools/hoverHighlight.js +4 -2
- package/trid/tools/index.js +3 -1
- package/trid/tools/orbitCamera.js +9 -0
- package/trid/tools/transformGizmo.js +316 -0
- package/trid/tools/viewGizmo.js +178 -0
package/trid/tools/dragObject.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|
package/trid/tools/helpers.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|
-
*
|
|
39
|
-
* bounding box (plus `padding`)
|
|
40
|
-
*
|
|
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
|
|
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.
|
|
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() {
|
package/trid/tools/index.js
CHANGED
|
@@ -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
|
+
}
|