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.
@@ -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
+ }