trid3d 0.0.0-stage → 0.1.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,241 @@
1
+ /**
2
+ * A tool plugs into a Scene to add interaction or extra drawing.
3
+ * Every method is optional.
4
+ *
5
+ * @typedef {Object} Tool
6
+ * @property {(scene:Scene) => void} [attach] - Called when the tool is added
7
+ * @property {(scene:Scene) => void} [detach] - Called when the tool is removed
8
+ * @property {(e:ToolPointerEvent, scene:Scene) => boolean} [pointerDown]
9
+ * Return true to take the drag: the following moves and the release
10
+ * go only to this tool
11
+ * @property {(e:ToolPointerEvent, scene:Scene) => void} [pointerMove]
12
+ * Called for the tool holding the drag, or for every tool while hovering
13
+ * @property {(e:ToolPointerEvent, scene:Scene) => void} [pointerUp] - Drag released
14
+ * @property {(scene:Scene) => void} [pointerLeave] - Pointer left the canvas (not dragging)
15
+ * @property {(e:ToolWheelEvent, scene:Scene) => boolean} [wheel]
16
+ * Return true to use the scroll (stops the page from scrolling)
17
+ * @property {(dt:number, scene:Scene) => void} [update] - Called every frame before the scene's update
18
+ * @property {(ctx:CanvasRenderingContext2D, scene:Scene) => void} [draw] - Called after the objects are drawn
19
+ * @property {boolean} [blockContextMenu] - Disable the right-click menu on the canvas
20
+ *
21
+ * @typedef {Object} ToolPointerEvent
22
+ * @property {number} x - Pointer X in canvas pixels
23
+ * @property {number} y - Pointer Y in canvas pixels
24
+ * @property {number} dx - X movement since the last event, in canvas pixels
25
+ * @property {number} dy - Y movement since the last event, in canvas pixels
26
+ * @property {number} button - Button that changed (0 left, 1 middle, 2 right)
27
+ * @property {boolean} shiftKey
28
+ * @property {boolean} ctrlKey
29
+ * @property {boolean} altKey
30
+ * @property {PointerEvent} original - The DOM event
31
+ *
32
+ * @typedef {Object} ToolWheelEvent
33
+ * @property {number} x - Pointer X in canvas pixels
34
+ * @property {number} y - Pointer Y in canvas pixels
35
+ * @property {number} deltaY - Scroll amount (> 0 scrolls down)
36
+ * @property {WheelEvent} original - The DOM event
37
+ *
38
+ * @typedef {import("../scene.js").Scene} Scene
39
+ */
40
+
41
+ /**
42
+ * Keeps a scene's tools and sends them canvas pointer events.
43
+ *
44
+ * Tools added later get the first chance at a pointer down or a
45
+ * scroll, like layers stacked on top of each other. The first tool
46
+ * that returns true takes it.
47
+ *
48
+ * Pointer positions are converted to canvas pixels, so they match
49
+ * the drawing even when the canvas is resized with CSS.
50
+ */
51
+ export class ToolManager {
52
+ /**
53
+ * @param {Scene} scene - Scene whose canvas receives the events
54
+ */
55
+ constructor(scene) {
56
+ this.scene = scene;
57
+ /** @type {Tool[]} */
58
+ this.list = [];
59
+
60
+ this._active = null;
61
+ this._pointerId = null;
62
+ this._last = { x: 0, y: 0 };
63
+ this._listening = false;
64
+
65
+ this._onDown = this._onDown.bind(this);
66
+ this._onMove = this._onMove.bind(this);
67
+ this._onUp = this._onUp.bind(this);
68
+ this._onLeave = this._onLeave.bind(this);
69
+ this._onWheel = this._onWheel.bind(this);
70
+ this._onContextMenu = this._onContextMenu.bind(this);
71
+ }
72
+
73
+ /**
74
+ * Adds a tool and calls its `attach`. Adding the same tool twice does nothing.
75
+ *
76
+ * @param {Tool} tool
77
+ */
78
+ add(tool) {
79
+ if (this.list.includes(tool)) return;
80
+ this.list.push(tool);
81
+ tool.attach?.(this.scene);
82
+ this._listen();
83
+ }
84
+
85
+ /**
86
+ * Removes a tool and calls its `detach`. Does nothing if it isn't added.
87
+ *
88
+ * @param {Tool} tool
89
+ */
90
+ remove(tool) {
91
+ const i = this.list.indexOf(tool);
92
+ if (i === -1) return;
93
+ if (this._active === tool) this._release();
94
+ this.list.splice(i, 1);
95
+ tool.detach?.(this.scene);
96
+ if (this.list.length === 0) this._unlisten();
97
+ }
98
+
99
+ /** Removes every tool. */
100
+ clear() {
101
+ for (const tool of [...this.list]) this.remove(tool);
102
+ }
103
+
104
+ /**
105
+ * Calls every tool's `update`.
106
+ *
107
+ * @param {number} dt - Seconds since the last frame
108
+ */
109
+ update(dt) {
110
+ for (const tool of this.list) tool.update?.(dt, this.scene);
111
+ }
112
+
113
+ /**
114
+ * Calls every tool's `draw`.
115
+ *
116
+ * @param {CanvasRenderingContext2D} ctx
117
+ */
118
+ draw(ctx) {
119
+ for (const tool of this.list) tool.draw?.(ctx, this.scene);
120
+ }
121
+
122
+ _listen() {
123
+ if (this._listening) return;
124
+ const c = this.scene.canvas;
125
+ c.addEventListener("pointerdown", this._onDown);
126
+ c.addEventListener("pointermove", this._onMove);
127
+ c.addEventListener("pointerup", this._onUp);
128
+ c.addEventListener("pointercancel", this._onUp);
129
+ c.addEventListener("pointerleave", this._onLeave);
130
+ c.addEventListener("wheel", this._onWheel, { passive: false });
131
+ c.addEventListener("contextmenu", this._onContextMenu);
132
+ // Let tools receive touch drags instead of the page scrolling
133
+ c.style.touchAction = "none";
134
+ this._listening = true;
135
+ }
136
+
137
+ _unlisten() {
138
+ if (!this._listening) return;
139
+ const c = this.scene.canvas;
140
+ c.removeEventListener("pointerdown", this._onDown);
141
+ c.removeEventListener("pointermove", this._onMove);
142
+ c.removeEventListener("pointerup", this._onUp);
143
+ c.removeEventListener("pointercancel", this._onUp);
144
+ c.removeEventListener("pointerleave", this._onLeave);
145
+ c.removeEventListener("wheel", this._onWheel);
146
+ c.removeEventListener("contextmenu", this._onContextMenu);
147
+ c.style.touchAction = "";
148
+ this._listening = false;
149
+ }
150
+
151
+ /** Converts a DOM event position to canvas pixels. */
152
+ _toCanvas(e) {
153
+ const c = this.scene.canvas;
154
+ const rect = c.getBoundingClientRect();
155
+ return {
156
+ x: (e.clientX - rect.left) * (c.width / rect.width),
157
+ y: (e.clientY - rect.top) * (c.height / rect.height),
158
+ };
159
+ }
160
+
161
+ _pointerEvent(e) {
162
+ const { x, y } = this._toCanvas(e);
163
+ const ev = {
164
+ x, y,
165
+ dx: x - this._last.x,
166
+ dy: y - this._last.y,
167
+ button: e.button,
168
+ shiftKey: e.shiftKey,
169
+ ctrlKey: e.ctrlKey,
170
+ altKey: e.altKey,
171
+ original: e,
172
+ };
173
+ this._last = { x, y };
174
+ return ev;
175
+ }
176
+
177
+ _onDown(e) {
178
+ if (this._active) return;
179
+ const ev = this._pointerEvent(e);
180
+ ev.dx = ev.dy = 0;
181
+
182
+ for (let i = this.list.length - 1; i >= 0; i--) {
183
+ const tool = this.list[i];
184
+ if (tool.pointerDown?.(ev, this.scene) === true) {
185
+ this._active = tool;
186
+ this._pointerId = e.pointerId;
187
+ this.scene.canvas.setPointerCapture?.(e.pointerId);
188
+ e.preventDefault();
189
+ return;
190
+ }
191
+ }
192
+ }
193
+
194
+ _onMove(e) {
195
+ if (this._active && e.pointerId !== this._pointerId) return;
196
+ const ev = this._pointerEvent(e);
197
+
198
+ if (this._active) {
199
+ this._active.pointerMove?.(ev, this.scene);
200
+ } else {
201
+ for (const tool of this.list) tool.pointerMove?.(ev, this.scene);
202
+ }
203
+ }
204
+
205
+ _onUp(e) {
206
+ if (!this._active || e.pointerId !== this._pointerId) return;
207
+ const ev = this._pointerEvent(e);
208
+ const tool = this._active;
209
+ this._release();
210
+ tool.pointerUp?.(ev, this.scene);
211
+ }
212
+
213
+ _onLeave() {
214
+ if (this._active) return;
215
+ for (const tool of this.list) tool.pointerLeave?.(this.scene);
216
+ }
217
+
218
+ _onWheel(e) {
219
+ const { x, y } = this._toCanvas(e);
220
+ const ev = { x, y, deltaY: e.deltaY, original: e };
221
+
222
+ for (let i = this.list.length - 1; i >= 0; i--) {
223
+ if (this.list[i].wheel?.(ev, this.scene) === true) {
224
+ e.preventDefault();
225
+ return;
226
+ }
227
+ }
228
+ }
229
+
230
+ _onContextMenu(e) {
231
+ if (this.list.some(tool => tool.blockContextMenu)) e.preventDefault();
232
+ }
233
+
234
+ _release() {
235
+ if (this._pointerId !== null) {
236
+ this.scene.canvas.releasePointerCapture?.(this._pointerId);
237
+ }
238
+ this._active = null;
239
+ this._pointerId = null;
240
+ }
241
+ }
@@ -0,0 +1,153 @@
1
+ import { readOBJFile, pickOBJFile } from "../loaders/obj.js";
2
+
3
+ /**
4
+ * Lets the user load .obj files into the scene.
5
+ *
6
+ * - Drag and drop a .obj file onto the canvas
7
+ * - Or call `open()` from a button click to show the file dialog
8
+ *
9
+ * By default the new model replaces the last one it loaded. Pass
10
+ * `onLoad` to decide yourself what happens with it.
11
+ */
12
+ export class ObjUploader {
13
+ /**
14
+ * @param {Object} [opts]
15
+ * @param {number|null} [opts.fit=1] - Size of the model's largest side (null keeps the file's units)
16
+ * @param {string} [opts.color] - Color of loaded models (default: the scene's color)
17
+ * @param {boolean} [opts.replace=true] - Remove the previously loaded model first
18
+ * @param {string} [opts.hint="Drop an .obj file here"] - Text shown while nothing is loaded ("" for none)
19
+ * @param {(obj:Object, file:File, scene:Scene) => void} [opts.onLoad] - Called with the object after it's added
20
+ * @param {(err:Error, file:File) => void} [opts.onError] - Called when a file can't be read
21
+ */
22
+ constructor({
23
+ fit = 1,
24
+ color,
25
+ replace = true,
26
+ hint = "Drop an .obj file here",
27
+ onLoad,
28
+ onError = (err) => console.error(err),
29
+ } = {}) {
30
+ this.fit = fit;
31
+ this.color = color;
32
+ this.replace = replace;
33
+ this.hint = hint;
34
+ this.onLoad = onLoad;
35
+ this.onError = onError;
36
+
37
+ /** The last object this tool added, or null. */
38
+ this.object = null;
39
+
40
+ this._scene = null;
41
+ this._dragging = false;
42
+ this._onDragOver = this._onDragOver.bind(this);
43
+ this._onDragLeave = this._onDragLeave.bind(this);
44
+ this._onDrop = this._onDrop.bind(this);
45
+ }
46
+
47
+ attach(scene) {
48
+ this._scene = scene;
49
+ const c = scene.canvas;
50
+ c.addEventListener("dragover", this._onDragOver);
51
+ c.addEventListener("dragleave", this._onDragLeave);
52
+ c.addEventListener("drop", this._onDrop);
53
+ }
54
+
55
+ detach(scene) {
56
+ const c = scene.canvas;
57
+ c.removeEventListener("dragover", this._onDragOver);
58
+ c.removeEventListener("dragleave", this._onDragLeave);
59
+ c.removeEventListener("drop", this._onDrop);
60
+ this._scene = null;
61
+ }
62
+
63
+ /**
64
+ * Shows the file dialog and loads the chosen file.
65
+ * Call it from a user action, like a button click.
66
+ *
67
+ * @returns {Promise<Object|null>} The added object, or null if cancelled or failed
68
+ */
69
+ async open() {
70
+ const file = await pickOBJFile();
71
+ return file ? this.load(file) : null;
72
+ }
73
+
74
+ /**
75
+ * Loads a .obj `File` (or `Blob`) into the scene.
76
+ *
77
+ * @param {File} file
78
+ * @returns {Promise<Object|null>} The added object, or null if it failed
79
+ */
80
+ async load(file) {
81
+ const scene = this._scene;
82
+ if (!scene) return null;
83
+
84
+ let mesh;
85
+ try {
86
+ mesh = await readOBJFile(file, this.fit == null ? {} : { fit: this.fit });
87
+ } catch (err) {
88
+ this.onError(err, file);
89
+ return null;
90
+ }
91
+ if (this._scene !== scene) return null;
92
+
93
+ if (this.replace && this.object) scene.remove(this.object);
94
+ const obj = { mesh };
95
+ if (this.color) obj.color = this.color;
96
+ this.object = scene.add(obj);
97
+ this.onLoad?.(this.object, file, scene);
98
+ this._redraw();
99
+ return this.object;
100
+ }
101
+
102
+ draw(ctx, scene) {
103
+ const { width, height } = scene.canvas;
104
+ const showHint = this.hint && !this.object;
105
+ if (!this._dragging && !showHint) return;
106
+
107
+ ctx.save();
108
+ if (this._dragging) {
109
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.6)";
110
+ ctx.lineWidth = 3;
111
+ ctx.setLineDash([10, 8]);
112
+ ctx.strokeRect(8, 8, width - 16, height - 16);
113
+ }
114
+ if (showHint || this._dragging) {
115
+ ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
116
+ ctx.font = "16px sans-serif";
117
+ ctx.textAlign = "center";
118
+ ctx.textBaseline = "middle";
119
+ ctx.fillText(this._dragging ? "Drop to load" : this.hint, width / 2, height / 2);
120
+ }
121
+ ctx.restore();
122
+ }
123
+
124
+ // A running scene redraws every frame; a stopped one needs a nudge
125
+ _redraw() {
126
+ if (this._scene && !this._scene.running) this._scene.render();
127
+ }
128
+
129
+ _onDragOver(e) {
130
+ if (!e.dataTransfer?.types.includes("Files")) return;
131
+ e.preventDefault(); // allows the drop
132
+ e.dataTransfer.dropEffect = "copy";
133
+ if (!this._dragging) {
134
+ this._dragging = true;
135
+ this._redraw();
136
+ }
137
+ }
138
+
139
+ _onDragLeave() {
140
+ this._dragging = false;
141
+ this._redraw();
142
+ }
143
+
144
+ _onDrop(e) {
145
+ e.preventDefault();
146
+ this._dragging = false;
147
+ this._redraw();
148
+ const files = [...(e.dataTransfer?.files ?? [])];
149
+ const file = files.find((f) => f.name.toLowerCase().endsWith(".obj"));
150
+ if (file) this.load(file);
151
+ else if (files.length) this.onError(new Error("Not an .obj file"), files[0]);
152
+ }
153
+ }
@@ -0,0 +1,132 @@
1
+ import { cameraForward, cameraRight, cameraUp } from "../math/transform.js";
2
+ import { worldPerPixel } from "./helpers.js";
3
+
4
+ const MAX_PITCH = Math.PI / 2 - 0.01;
5
+
6
+ /**
7
+ * Mouse camera controls that turn around a target point.
8
+ *
9
+ * - Left drag: orbit around the target
10
+ * - Right drag, middle drag or Shift + left drag: pan (moves the target)
11
+ * - Scroll: zoom in and out
12
+ *
13
+ * The tool drives `scene.camera` (x, y, z, yaw, pitch). Its own
14
+ * `target`, `distance`, `yaw` and `pitch` can be changed at any time.
15
+ * Don't use it together with another tool that moves the camera.
16
+ */
17
+ export class OrbitCamera {
18
+ /**
19
+ * @param {Object} [opts]
20
+ * @param {{x:number,y:number,z:number}} [opts.target={x:0,y:0,z:0}] - Point to orbit around
21
+ * @param {number} [opts.distance] - Distance to the target (default: the camera's current distance)
22
+ * @param {number} [opts.yaw] - Starting yaw (default: the camera's yaw)
23
+ * @param {number} [opts.pitch] - Starting pitch (default: the camera's pitch)
24
+ * @param {number} [opts.rotateSpeed=0.005] - Radians per dragged pixel
25
+ * @param {number} [opts.zoomSpeed=0.001] - Zoom amount per scroll unit
26
+ * @param {number} [opts.minDistance=0.1] - Closest zoom
27
+ * @param {number} [opts.maxDistance=100] - Farthest zoom
28
+ * @param {boolean} [opts.rotate=true] - Allow orbiting
29
+ * @param {boolean} [opts.pan=true] - Allow panning
30
+ * @param {boolean} [opts.zoom=true] - Allow zooming
31
+ */
32
+ constructor({
33
+ target = { x: 0, y: 0, z: 0 },
34
+ distance,
35
+ yaw,
36
+ pitch,
37
+ rotateSpeed = 0.005,
38
+ zoomSpeed = 0.001,
39
+ minDistance = 0.1,
40
+ maxDistance = 100,
41
+ rotate = true,
42
+ pan = true,
43
+ zoom = true,
44
+ } = {}) {
45
+ this.target = target;
46
+ this.distance = distance;
47
+ this.yaw = yaw;
48
+ this.pitch = pitch;
49
+ this.rotateSpeed = rotateSpeed;
50
+ this.zoomSpeed = zoomSpeed;
51
+ this.minDistance = minDistance;
52
+ this.maxDistance = maxDistance;
53
+ this.rotate = rotate;
54
+ this.pan = pan;
55
+ this.zoom = zoom;
56
+
57
+ this.blockContextMenu = true;
58
+ this._mode = null;
59
+ }
60
+
61
+ attach(scene) {
62
+ const cam = scene.camera;
63
+ const t = this.target;
64
+ this.yaw ??= cam.yaw ?? 0;
65
+ this.pitch ??= cam.pitch ?? 0;
66
+ this.distance ??= Math.hypot(
67
+ (cam.x ?? 0) - t.x, (cam.y ?? 0) - t.y, (cam.z ?? 0) - t.z
68
+ ) || 1;
69
+ this.apply(cam);
70
+ }
71
+
72
+ /**
73
+ * Places the camera from the target, distance, yaw and pitch.
74
+ * Called automatically every frame and after each drag or scroll.
75
+ *
76
+ * @param {import("../math/transform.js").Camera} camera - Camera to move
77
+ */
78
+ apply(camera) {
79
+ this.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, this.pitch));
80
+ this.distance = Math.max(this.minDistance, Math.min(this.maxDistance, this.distance));
81
+
82
+ camera.yaw = this.yaw;
83
+ camera.pitch = this.pitch;
84
+ const f = cameraForward(camera);
85
+ camera.x = this.target.x - f.x * this.distance;
86
+ camera.y = this.target.y - f.y * this.distance;
87
+ camera.z = this.target.z - f.z * this.distance;
88
+ }
89
+
90
+ pointerDown(e) {
91
+ const panButton = e.button === 1 || e.button === 2 || (e.button === 0 && e.shiftKey);
92
+
93
+ if (panButton && this.pan) this._mode = "pan";
94
+ else if (e.button === 0 && this.rotate) this._mode = "rotate";
95
+ else return false;
96
+
97
+ return true;
98
+ }
99
+
100
+ pointerMove(e, scene) {
101
+ if (this._mode === "rotate") {
102
+ this.yaw += e.dx * this.rotateSpeed;
103
+ this.pitch -= e.dy * this.rotateSpeed;
104
+ } else if (this._mode === "pan") {
105
+ // Move the target so the point under the cursor follows it
106
+ const k = worldPerPixel(scene, this.distance);
107
+ const r = cameraRight(scene.camera);
108
+ const u = cameraUp(scene.camera);
109
+ this.target.x += (-r.x * e.dx + u.x * e.dy) * k;
110
+ this.target.y += (-r.y * e.dx + u.y * e.dy) * k;
111
+ this.target.z += (-r.z * e.dx + u.z * e.dy) * k;
112
+ } else {
113
+ return;
114
+ }
115
+ this.apply(scene.camera);
116
+ }
117
+
118
+ pointerUp() {
119
+ this._mode = null;
120
+ }
121
+
122
+ wheel(e, scene) {
123
+ if (!this.zoom) return false;
124
+ this.distance *= Math.exp(e.deltaY * this.zoomSpeed);
125
+ this.apply(scene.camera);
126
+ return true;
127
+ }
128
+
129
+ update(dt, scene) {
130
+ this.apply(scene.camera);
131
+ }
132
+ }