trid3d 0.2.0 → 0.3.1
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 +38 -18
- package/package.json +1 -1
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +107 -21
- package/trid/index.d.ts +419 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { cameraForward, cameraRight, cameraUp } from "../math/transform.js";
|
|
2
|
+
import { OrbitCamera } from "./orbitCamera.js";
|
|
3
|
+
|
|
4
|
+
const AXES = [
|
|
5
|
+
{ dir: { x: 1, y: 0, z: 0 }, label: "X", color: "#e8605a" },
|
|
6
|
+
{ dir: { x: 0, y: 1, z: 0 }, label: "Y", color: "#7cc46b" },
|
|
7
|
+
{ dir: { x: 0, y: 0, z: 1 }, label: "Z", color: "#5f9be8" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The X, Y and Z axes in a corner of the canvas, turning with the camera
|
|
14
|
+
* (like Blender's navigation gizmo). Axes pointing away from you are dimmed;
|
|
15
|
+
* the dots without a letter are the negative ends. Click an axis and the
|
|
16
|
+
* `OrbitCamera` smoothly turns to look along it.
|
|
17
|
+
*
|
|
18
|
+
* Add it **after** the OrbitCamera: tools added later get clicks first, so
|
|
19
|
+
* clicks on the gizmo don't start an orbit drag.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* scene.use(new OrbitCamera());
|
|
23
|
+
* const gizmo = scene.use(new ViewGizmo({ corner: "top-right" }));
|
|
24
|
+
* gizmo.lookAlong({ x: 0, y: 1, z: 0 }); // look down from above
|
|
25
|
+
*/
|
|
26
|
+
export class ViewGizmo {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} [opts]
|
|
29
|
+
* @param {OrbitCamera} [opts.orbit] - Camera controls to turn (default: the scene's first OrbitCamera).
|
|
30
|
+
* Without one, the gizmo only shows the axes
|
|
31
|
+
* @param {"top-right"|"top-left"|"bottom-right"|"bottom-left"} [opts.corner="top-right"] - Where it sits
|
|
32
|
+
* @param {number} [opts.size=42] - Radius in CSS pixels
|
|
33
|
+
* @param {number|{x:number,y:number}} [opts.margin=14] - Distance from the canvas corner in CSS pixels
|
|
34
|
+
* @param {number} [opts.duration=0.35] - Seconds the camera takes to turn to a clicked axis
|
|
35
|
+
*/
|
|
36
|
+
constructor({ orbit, corner = "top-right", size = 42, margin = 14, duration = 0.35 } = {}) {
|
|
37
|
+
this.orbit = orbit ?? null;
|
|
38
|
+
this.corner = corner;
|
|
39
|
+
this.size = size;
|
|
40
|
+
this.margin = typeof margin === "number" ? { x: margin, y: margin } : margin;
|
|
41
|
+
this.duration = duration;
|
|
42
|
+
/** Hide or show the gizmo without removing the tool. */
|
|
43
|
+
this.visible = true;
|
|
44
|
+
|
|
45
|
+
this._scene = null;
|
|
46
|
+
this._bubbles = []; // clickable axis ends, far to near, from the last draw
|
|
47
|
+
this._disc = null;
|
|
48
|
+
this._turn = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
attach(scene) {
|
|
52
|
+
this._scene = scene;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
detach() {
|
|
56
|
+
this._scene = null;
|
|
57
|
+
this._turn = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The OrbitCamera it turns: the one given, or the scene's first. */
|
|
61
|
+
_orbit() {
|
|
62
|
+
return this.orbit ?? this._scene?.tools.list.find((t) => t instanceof OrbitCamera) ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a canvas pixel is on the gizmo (as last drawn).
|
|
67
|
+
*
|
|
68
|
+
* @param {number} x - Canvas X in pixels
|
|
69
|
+
* @param {number} y - Canvas Y in pixels
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
contains(x, y) {
|
|
73
|
+
return this.visible && !!this._disc && Math.hypot(x - this._disc.x, y - this._disc.y) <= this._disc.r;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Turns the camera smoothly so it looks at its target from `dir`:
|
|
78
|
+
* `{ x: 1, y: 0, z: 0 }` looks from the +X side, `{ x: 0, y: 1, z: 0 }` from above.
|
|
79
|
+
*
|
|
80
|
+
* @param {{x:number,y:number,z:number}} dir - Direction from the target to the camera
|
|
81
|
+
*/
|
|
82
|
+
lookAlong(dir) {
|
|
83
|
+
const orbit = this._orbit();
|
|
84
|
+
if (!orbit) return;
|
|
85
|
+
const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
|
|
86
|
+
// The camera looks against `dir`, toward the target
|
|
87
|
+
const f = { x: -dir.x / len, y: -dir.y / len, z: -dir.z / len };
|
|
88
|
+
const pitch1 = Math.asin(Math.max(-1, Math.min(1, f.y)));
|
|
89
|
+
// Straight up or down: keep the current heading
|
|
90
|
+
const yaw1 = Math.abs(f.y) > 0.999 ? orbit.yaw : Math.atan2(f.x, f.z);
|
|
91
|
+
// Turn the short way round
|
|
92
|
+
let yaw0 = orbit.yaw;
|
|
93
|
+
while (yaw1 - yaw0 > Math.PI) yaw0 += Math.PI * 2;
|
|
94
|
+
while (yaw0 - yaw1 > Math.PI) yaw0 -= Math.PI * 2;
|
|
95
|
+
this._turn = { orbit, yaw0, pitch0: orbit.pitch, yaw1, pitch1, t: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pointerDown(e) {
|
|
99
|
+
if (!this.visible || e.button !== 0 || !this._orbit()) return false;
|
|
100
|
+
// The nearest end wins where two overlap (bubbles are stored far to near)
|
|
101
|
+
const hit = [...this._bubbles].reverse().find((b) => Math.hypot(e.x - b.x, e.y - b.y) <= b.r);
|
|
102
|
+
if (!hit) return false;
|
|
103
|
+
this.lookAlong(hit.dir);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
update(dt, scene) {
|
|
108
|
+
const turn = this._turn;
|
|
109
|
+
if (!turn) return;
|
|
110
|
+
turn.t = this.duration > 0 ? Math.min(1, turn.t + dt / this.duration) : 1;
|
|
111
|
+
const e = 1 - Math.pow(1 - turn.t, 3); // ease out
|
|
112
|
+
turn.orbit.yaw = turn.yaw0 + (turn.yaw1 - turn.yaw0) * e;
|
|
113
|
+
turn.orbit.pitch = turn.pitch0 + (turn.pitch1 - turn.pitch0) * e;
|
|
114
|
+
turn.orbit.holdAutoRotate?.();
|
|
115
|
+
turn.orbit.apply(scene.camera);
|
|
116
|
+
if (turn.t >= 1) this._turn = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
draw(ctx, scene) {
|
|
120
|
+
this._bubbles = [];
|
|
121
|
+
this._disc = null;
|
|
122
|
+
if (!this.visible) return;
|
|
123
|
+
const { canvas, camera } = scene;
|
|
124
|
+
const k = canvas.width / (canvas.clientWidth || canvas.width); // CSS px → canvas px
|
|
125
|
+
const r = this.size * k, mx = this.margin.x * k, my = this.margin.y * k;
|
|
126
|
+
const cx = this.corner.endsWith("right") ? canvas.width - mx - r : mx + r;
|
|
127
|
+
const cy = this.corner.startsWith("top") ? my + r : canvas.height - my - r;
|
|
128
|
+
this._disc = { x: cx, y: cy, r };
|
|
129
|
+
const right = cameraRight(camera), up = cameraUp(camera), fwd = cameraForward(camera);
|
|
130
|
+
|
|
131
|
+
ctx.save();
|
|
132
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
|
|
133
|
+
ctx.beginPath();
|
|
134
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
135
|
+
ctx.fill();
|
|
136
|
+
|
|
137
|
+
const reach = r * 0.68, dotR = r * 0.2;
|
|
138
|
+
const items = AXES.flatMap((a) => [1, -1].map((sign) => {
|
|
139
|
+
const dir = { x: a.dir.x * sign, y: a.dir.y * sign, z: a.dir.z * sign };
|
|
140
|
+
return {
|
|
141
|
+
...a, sign, dir,
|
|
142
|
+
x: cx + dot(dir, right) * reach,
|
|
143
|
+
y: cy - dot(dir, up) * reach,
|
|
144
|
+
depth: dot(dir, fwd), // > 0: points away from the viewer
|
|
145
|
+
};
|
|
146
|
+
}));
|
|
147
|
+
// Far ones first, so the near ones are drawn on top
|
|
148
|
+
items.sort((a, b) => b.depth - a.depth);
|
|
149
|
+
|
|
150
|
+
for (const it of items) {
|
|
151
|
+
const fade = 0.55 + 0.45 * Math.max(0, -it.depth);
|
|
152
|
+
if (it.sign > 0) {
|
|
153
|
+
ctx.strokeStyle = it.color;
|
|
154
|
+
ctx.globalAlpha = fade;
|
|
155
|
+
ctx.lineWidth = 2 * k;
|
|
156
|
+
ctx.beginPath();
|
|
157
|
+
ctx.moveTo(cx, cy);
|
|
158
|
+
ctx.lineTo(it.x, it.y);
|
|
159
|
+
ctx.stroke();
|
|
160
|
+
}
|
|
161
|
+
ctx.globalAlpha = it.sign > 0 ? fade : fade * 0.6;
|
|
162
|
+
ctx.fillStyle = it.color;
|
|
163
|
+
ctx.beginPath();
|
|
164
|
+
ctx.arc(it.x, it.y, it.sign > 0 ? dotR : dotR * 0.7, 0, Math.PI * 2);
|
|
165
|
+
ctx.fill();
|
|
166
|
+
if (it.sign > 0) {
|
|
167
|
+
ctx.globalAlpha = 1;
|
|
168
|
+
ctx.fillStyle = "#15171c";
|
|
169
|
+
ctx.font = `600 ${Math.round(dotR * 1.1)}px ui-monospace, monospace`;
|
|
170
|
+
ctx.textAlign = "center";
|
|
171
|
+
ctx.textBaseline = "middle";
|
|
172
|
+
ctx.fillText(it.label, it.x, it.y + 0.5 * k);
|
|
173
|
+
}
|
|
174
|
+
this._bubbles.push({ x: it.x, y: it.y, r: dotR * 1.3, dir: it.dir });
|
|
175
|
+
}
|
|
176
|
+
ctx.restore();
|
|
177
|
+
}
|
|
178
|
+
}
|