trid3d 0.0.0-stage → 0.1.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/trid/scene.js ADDED
@@ -0,0 +1,167 @@
1
+ import { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./render/canvasRenderer.js";
2
+ import { loop } from "./render/loop.js";
3
+ import { ToolManager } from "./tools/manager.js";
4
+
5
+ /**
6
+ * Holds a canvas, a camera and a list of objects, and draws them.
7
+ *
8
+ * Objects are mesh instances. Any missing transform field is filled
9
+ * with a default when the object is added:
10
+ * - `pos` → { x: 0, y: 0, z: 0 }
11
+ * - `rot` → { x: 0, y: 0, z: 0 }
12
+ * - `scale` → { x: 1, y: 1, z: 1 }
13
+ *
14
+ * Each object can also set its own `color` and `lineWidth`;
15
+ * otherwise the scene's defaults are used. An object with a
16
+ * `material` draws its mesh's faces (filled and shaded) instead of
17
+ * only lines: `{ mesh, material: { fill: "#E9806E" } }`.
18
+ *
19
+ * Tools (camera controls, dragging, overlays...) are added with
20
+ * `use`. See `trid/tools/`.
21
+ *
22
+ * Typical usage:
23
+ * const scene = new Scene(canvas, { width: 800, height: 800 });
24
+ * const box = scene.add({ mesh: makeShape("cube"), color: "#E9806E" });
25
+ * scene.start((dt) => { box.rot.y += dt; });
26
+ */
27
+ export class Scene {
28
+ /**
29
+ * @param {HTMLCanvasElement} canvas - Canvas to draw on
30
+ * @param {Object} [opts]
31
+ * @param {number} [opts.width] - Sets canvas.width (keeps the current one if omitted)
32
+ * @param {number} [opts.height] - Sets canvas.height (keeps the current one if omitted)
33
+ * @param {string} [opts.background="#141414"] - Clear color
34
+ * @param {string} [opts.color="#ffffff"] - Default wireframe color
35
+ * @param {number} [opts.lineWidth=3] - Default wireframe width
36
+ * @param {import("./math/transform.js").Camera} [opts.camera={ z: -1 }] - Camera
37
+ * @param {{x:number,y:number,z:number}} [opts.light] - Direction light comes from
38
+ * (default: above, left and in front)
39
+ * @param {number} [opts.ambient=0.25] - Brightness of faces turned away from the light (0..1)
40
+ * @param {{ render(scene:Scene): void, dispose?(): void }} [opts.renderer] - What draws the scene
41
+ * (default: a new `CanvasRenderer`; or a `WebGLRenderer`)
42
+ */
43
+ constructor(canvas, {
44
+ width,
45
+ height,
46
+ background = "#141414",
47
+ color = "#ffffff",
48
+ lineWidth = 3,
49
+ camera = { z: -1 },
50
+ light = { ...DEFAULT_LIGHT },
51
+ ambient = DEFAULT_AMBIENT,
52
+ renderer = new CanvasRenderer(),
53
+ } = {}) {
54
+ this.canvas = canvas;
55
+ this.ctx = canvas.getContext("2d");
56
+ if (width !== undefined) canvas.width = width;
57
+ if (height !== undefined) canvas.height = height;
58
+
59
+ this.background = background;
60
+ this.color = color;
61
+ this.lineWidth = lineWidth;
62
+ this.camera = camera;
63
+ this.light = light;
64
+ this.ambient = ambient;
65
+ this.renderer = renderer;
66
+ this.objects = [];
67
+ this.tools = new ToolManager(this);
68
+
69
+ this._stop = null;
70
+ }
71
+
72
+ /**
73
+ * Adds an object to the scene, filling in missing transform fields.
74
+ *
75
+ * @param {{ mesh:{ vertices:{x:number,y:number,z:number}[], edges:number[][] } }} obj - Mesh instance
76
+ * @returns The same object, so it can be kept and animated
77
+ */
78
+ add(obj) {
79
+ obj.pos ??= { x: 0, y: 0, z: 0 };
80
+ obj.rot ??= { x: 0, y: 0, z: 0 };
81
+ obj.scale ??= { x: 1, y: 1, z: 1 };
82
+ this.objects.push(obj);
83
+ return obj;
84
+ }
85
+
86
+ /**
87
+ * Removes an object from the scene. Does nothing if it isn't in it.
88
+ *
89
+ * @param {Object} obj - Object previously passed to `add`
90
+ */
91
+ remove(obj) {
92
+ const i = this.objects.indexOf(obj);
93
+ if (i !== -1) this.objects.splice(i, 1);
94
+ }
95
+
96
+ /**
97
+ * Adds a tool to the scene. Tools added later get the first chance
98
+ * at mouse clicks and scrolls, so add camera tools first and
99
+ * object tools (like `DragObject`) after them.
100
+ *
101
+ * @param {import("./tools/manager.js").Tool} tool - Tool to add
102
+ * @returns The same tool
103
+ */
104
+ use(tool) {
105
+ this.tools.add(tool);
106
+ return tool;
107
+ }
108
+
109
+ /**
110
+ * Removes a tool from the scene. Does nothing if it isn't in it.
111
+ *
112
+ * @param {import("./tools/manager.js").Tool} tool - Tool previously passed to `use`
113
+ */
114
+ unuse(tool) {
115
+ this.tools.remove(tool);
116
+ }
117
+
118
+ /**
119
+ * Clears the canvas, draws every object once (through the
120
+ * renderer), then lets tools draw on top.
121
+ */
122
+ render() {
123
+ this.renderer.render(this);
124
+ this.tools.draw(this.ctx);
125
+ }
126
+
127
+ /**
128
+ * Starts the frame loop: each frame updates the tools, calls
129
+ * `update`, then calls `render`.
130
+ * Calling `start` while running restarts the loop with the new `update`.
131
+ *
132
+ * @param {(dt:number, time:number, scene:Scene) => void} [update] - Per-frame logic
133
+ * @returns {Scene} This scene
134
+ */
135
+ start(update) {
136
+ this.stop();
137
+ this._stop = loop((dt, time) => {
138
+ this.tools.update(dt);
139
+ update?.(dt, time, this);
140
+ this.render();
141
+ });
142
+ return this;
143
+ }
144
+
145
+ /**
146
+ * Stops the frame loop. The last frame stays on the canvas.
147
+ */
148
+ stop() {
149
+ this._stop?.();
150
+ this._stop = null;
151
+ }
152
+
153
+ /**
154
+ * Stops the loop, removes every tool (and its event listeners) and
155
+ * frees the renderer's resources. Use it when the scene is no longer needed.
156
+ */
157
+ destroy() {
158
+ this.stop();
159
+ this.tools.clear();
160
+ this.renderer.dispose?.();
161
+ }
162
+
163
+ /** Whether the frame loop is running. */
164
+ get running() {
165
+ return this._stop !== null;
166
+ }
167
+ }
@@ -0,0 +1,89 @@
1
+ import { worldToCamera, cameraForward, cameraRight, cameraUp } from "../math/transform.js";
2
+ import { pick, worldPerPixel } from "./helpers.js";
3
+
4
+ /**
5
+ * Click an object to pick it, then drag to move it.
6
+ *
7
+ * - Drag: moves the object parallel to the screen, following the cursor
8
+ * - Shift + drag: moves it toward or away from the camera instead
9
+ *
10
+ * Objects are picked with `pick` (screen-space bounding box).
11
+ * Set `obj.draggable = false` to make an object ignore this tool.
12
+ *
13
+ * Add this tool after a camera tool so clicking an object drags it
14
+ * and clicking empty space still moves the camera.
15
+ */
16
+ export class DragObject {
17
+ /**
18
+ * @param {Object} [opts]
19
+ * @param {number} [opts.button=0] - Mouse button that drags (0 left, 1 middle, 2 right)
20
+ * @param {(obj:Object) => boolean} [opts.filter] - Which objects can be dragged
21
+ * (default: those without `draggable: false`)
22
+ * @param {(obj:Object) => void} [opts.onPick] - Called when an object is picked
23
+ * @param {(obj:Object) => void} [opts.onMove] - Called each time the object moves
24
+ * @param {(obj:Object) => void} [opts.onDrop] - Called when the object is released
25
+ */
26
+ constructor({
27
+ button = 0,
28
+ filter = obj => obj.draggable !== false,
29
+ onPick,
30
+ onMove,
31
+ onDrop,
32
+ } = {}) {
33
+ this.button = button;
34
+ this.filter = filter;
35
+ this.onPick = onPick;
36
+ this.onMove = onMove;
37
+ this.onDrop = onDrop;
38
+
39
+ /** The object being dragged, or null. */
40
+ this.dragging = null;
41
+ }
42
+
43
+ pointerDown(e, scene) {
44
+ if (e.button !== this.button) return false;
45
+
46
+ const obj = pick(scene, e.x, e.y, { filter: this.filter });
47
+ if (!obj) return false;
48
+
49
+ this.dragging = obj;
50
+ this.onPick?.(obj);
51
+ return true;
52
+ }
53
+
54
+ pointerMove(e, scene) {
55
+ const obj = this.dragging;
56
+ if (!obj) return;
57
+
58
+ const cam = scene.camera;
59
+ const depth = worldToCamera(obj.pos, cam).z;
60
+ const k = worldPerPixel(scene, depth);
61
+
62
+ if (e.shiftKey) {
63
+ // Drag up pushes the object away, drag down pulls it closer
64
+ const f = cameraForward(cam);
65
+ 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;
69
+ } else {
70
+ const r = cameraRight(cam);
71
+ 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;
75
+ }
76
+
77
+ this.onMove?.(obj);
78
+ }
79
+
80
+ pointerUp() {
81
+ const obj = this.dragging;
82
+ this.dragging = null;
83
+ if (obj) this.onDrop?.(obj);
84
+ }
85
+
86
+ detach() {
87
+ this.dragging = null;
88
+ }
89
+ }
@@ -0,0 +1,111 @@
1
+ import { cameraForward, cameraRight } from "../math/transform.js";
2
+
3
+ const MAX_PITCH = Math.PI / 2 - 0.01;
4
+
5
+ /**
6
+ * Keyboard + mouse camera that flies freely through the scene.
7
+ *
8
+ * - W / S: forward / back
9
+ * - A / D: left / right
10
+ * - E / Q: up / down
11
+ * - Arrow keys: turn
12
+ * - Left drag: look around
13
+ * - Hold Shift: move faster
14
+ *
15
+ * Keys use physical positions (`KeyboardEvent.code`), so on AZERTY
16
+ * the same keys are Z / Q / S / D. Keys are ignored while typing in
17
+ * an input field. Don't use it together with another tool that
18
+ * moves the camera.
19
+ */
20
+ export class FlyCamera {
21
+ /**
22
+ * @param {Object} [opts]
23
+ * @param {number} [opts.speed=1] - Units per second
24
+ * @param {number} [opts.fastMultiplier=3] - Speed multiplier while Shift is held
25
+ * @param {number} [opts.turnSpeed=1.5] - Radians per second with the arrow keys
26
+ * @param {number} [opts.lookSpeed=0.005] - Radians per dragged pixel
27
+ */
28
+ constructor({ speed = 1, fastMultiplier = 3, turnSpeed = 1.5, lookSpeed = 0.005 } = {}) {
29
+ this.speed = speed;
30
+ this.fastMultiplier = fastMultiplier;
31
+ this.turnSpeed = turnSpeed;
32
+ this.lookSpeed = lookSpeed;
33
+
34
+ this._keys = new Set();
35
+ this._onKeyDown = this._onKeyDown.bind(this);
36
+ this._onKeyUp = this._onKeyUp.bind(this);
37
+ this._onBlur = () => this._keys.clear();
38
+ }
39
+
40
+ attach() {
41
+ window.addEventListener("keydown", this._onKeyDown);
42
+ window.addEventListener("keyup", this._onKeyUp);
43
+ window.addEventListener("blur", this._onBlur);
44
+ }
45
+
46
+ detach() {
47
+ window.removeEventListener("keydown", this._onKeyDown);
48
+ window.removeEventListener("keyup", this._onKeyUp);
49
+ window.removeEventListener("blur", this._onBlur);
50
+ this._keys.clear();
51
+ }
52
+
53
+ _onKeyDown(e) {
54
+ const tag = e.target?.tagName;
55
+ if (tag === "INPUT" || tag === "TEXTAREA" || e.target?.isContentEditable) return;
56
+ if (!HANDLED_KEYS.has(e.code)) return;
57
+
58
+ this._keys.add(e.code);
59
+ // Stop arrow keys from scrolling the page
60
+ if (e.code.startsWith("Arrow")) e.preventDefault();
61
+ }
62
+
63
+ _onKeyUp(e) {
64
+ this._keys.delete(e.code);
65
+ }
66
+
67
+ pointerDown(e) {
68
+ return e.button === 0;
69
+ }
70
+
71
+ pointerMove(e, scene) {
72
+ const cam = scene.camera;
73
+ cam.yaw = (cam.yaw ?? 0) + e.dx * this.lookSpeed;
74
+ cam.pitch = clampPitch((cam.pitch ?? 0) - e.dy * this.lookSpeed);
75
+ }
76
+
77
+ update(dt, scene) {
78
+ const keys = this._keys;
79
+ if (keys.size === 0) return;
80
+
81
+ const cam = scene.camera;
82
+ const turn = this.turnSpeed * dt;
83
+ if (keys.has("ArrowLeft")) cam.yaw = (cam.yaw ?? 0) - turn;
84
+ if (keys.has("ArrowRight")) cam.yaw = (cam.yaw ?? 0) + turn;
85
+ if (keys.has("ArrowUp")) cam.pitch = clampPitch((cam.pitch ?? 0) + turn);
86
+ if (keys.has("ArrowDown")) cam.pitch = clampPitch((cam.pitch ?? 0) - turn);
87
+
88
+ const fast = keys.has("ShiftLeft") || keys.has("ShiftRight");
89
+ const step = this.speed * (fast ? this.fastMultiplier : 1) * dt;
90
+ const f = cameraForward(cam);
91
+ const r = cameraRight(cam);
92
+
93
+ const forward = (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0);
94
+ const right = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0);
95
+ const up = (keys.has("KeyE") ? 1 : 0) - (keys.has("KeyQ") ? 1 : 0);
96
+
97
+ cam.x = (cam.x ?? 0) + (f.x * forward + r.x * right) * step;
98
+ cam.y = (cam.y ?? 0) + (f.y * forward + up) * step;
99
+ cam.z = (cam.z ?? 0) + (f.z * forward + r.z * right) * step;
100
+ }
101
+ }
102
+
103
+ const HANDLED_KEYS = new Set([
104
+ "KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE",
105
+ "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
106
+ "ShiftLeft", "ShiftRight",
107
+ ]);
108
+
109
+ function clampPitch(pitch) {
110
+ return Math.max(-MAX_PITCH, Math.min(MAX_PITCH, pitch));
111
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Draws the frames per second in a corner of the canvas.
3
+ *
4
+ * The value is averaged and refreshed every `interval` seconds,
5
+ * so it stays readable. It only counts while the scene is running.
6
+ */
7
+ export class FpsMeter {
8
+ /**
9
+ * @param {Object} [opts]
10
+ * @param {string} [opts.color="#ffffff"] - Text color
11
+ * @param {string} [opts.font="14px monospace"] - CSS font
12
+ * @param {"top-left"|"top-right"|"bottom-left"|"bottom-right"} [opts.corner="top-left"]
13
+ * @param {number} [opts.interval=0.5] - Seconds between refreshes
14
+ */
15
+ constructor({ color = "#ffffff", font = "14px monospace", corner = "top-left", interval = 0.5 } = {}) {
16
+ this.color = color;
17
+ this.font = font;
18
+ this.corner = corner;
19
+ this.interval = interval;
20
+
21
+ /** Last measured frames per second. */
22
+ this.fps = 0;
23
+ this._frames = 0;
24
+ this._time = 0;
25
+ }
26
+
27
+ update(dt) {
28
+ // The loop's first frame has dt = 0; counting it would inflate the reading
29
+ if (dt <= 0) return;
30
+ this._frames++;
31
+ this._time += dt;
32
+ if (this._time >= this.interval) {
33
+ this.fps = Math.round(this._frames / this._time);
34
+ this._frames = 0;
35
+ this._time = 0;
36
+ }
37
+ }
38
+
39
+ draw(ctx, scene) {
40
+ const pad = 8;
41
+ const { width, height } = scene.canvas;
42
+ const right = this.corner.endsWith("right");
43
+ const bottom = this.corner.startsWith("bottom");
44
+
45
+ ctx.font = this.font;
46
+ ctx.fillStyle = this.color;
47
+ ctx.textAlign = right ? "right" : "left";
48
+ ctx.textBaseline = bottom ? "bottom" : "top";
49
+ ctx.fillText(`${this.fps} FPS`, right ? width - pad : pad, bottom ? height - pad : pad);
50
+ }
51
+ }
@@ -0,0 +1,85 @@
1
+ import { localToWorld, worldToCamera, cameraToScreen, DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
+
3
+ /**
4
+ * Screen-space bounding box of an object, in canvas pixels.
5
+ * Only vertices in front of the camera's near plane are used.
6
+ *
7
+ * @param {import("../scene.js").Scene} scene - Scene the object is in
8
+ * @param {Object} obj - Mesh instance (mesh, pos, rot, scale)
9
+ * @returns {{minX:number, minY:number, maxX:number, maxY:number} | null}
10
+ * The box, or null if the whole object is behind the camera
11
+ */
12
+ export function screenBounds(scene, obj) {
13
+ const { camera, canvas } = scene;
14
+ const near = camera.near ?? DEFAULT_NEAR;
15
+ let box = null;
16
+
17
+ for (const v of obj.mesh.vertices) {
18
+ const pCam = worldToCamera(localToWorld(v, obj), camera);
19
+ if (pCam.z < near) continue;
20
+
21
+ const p = cameraToScreen(pCam, camera, canvas.width, canvas.height);
22
+ if (!box) {
23
+ box = { minX: p.x, minY: p.y, maxX: p.x, maxY: p.y };
24
+ } else {
25
+ box.minX = Math.min(box.minX, p.x);
26
+ box.minY = Math.min(box.minY, p.y);
27
+ box.maxX = Math.max(box.maxX, p.x);
28
+ box.maxY = Math.max(box.maxY, p.y);
29
+ }
30
+ }
31
+
32
+ return box;
33
+ }
34
+
35
+ /**
36
+ * Finds the object under a canvas pixel.
37
+ *
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.
41
+ *
42
+ * @param {import("../scene.js").Scene} scene - Scene to search
43
+ * @param {number} x - Canvas X in pixels
44
+ * @param {number} y - Canvas Y in pixels
45
+ * @param {Object} [opts]
46
+ * @param {(obj:Object) => boolean} [opts.filter] - Only consider objects that pass
47
+ * @param {number} [opts.padding=4] - Extra pixels around each box
48
+ * @returns {Object | null} The object, or null if nothing is hit
49
+ */
50
+ export function pick(scene, x, y, { filter = () => true, padding = 4 } = {}) {
51
+ const near = scene.camera.near ?? DEFAULT_NEAR;
52
+ let best = null;
53
+ let bestDepth = Infinity;
54
+
55
+ for (const obj of scene.objects) {
56
+ if (!filter(obj)) continue;
57
+
58
+ const depth = worldToCamera(obj.pos, scene.camera).z;
59
+ if (depth < near || depth >= bestDepth) continue;
60
+
61
+ const box = screenBounds(scene, obj);
62
+ if (!box) continue;
63
+
64
+ if (x >= box.minX - padding && x <= box.maxX + padding &&
65
+ y >= box.minY - padding && y <= box.maxY + padding) {
66
+ best = obj;
67
+ bestDepth = depth;
68
+ }
69
+ }
70
+
71
+ return best;
72
+ }
73
+
74
+ /**
75
+ * How many world units one canvas pixel covers at a given distance
76
+ * from the camera. Useful to make things follow the cursor exactly.
77
+ *
78
+ * @param {import("../scene.js").Scene} scene - Scene (for camera fov and canvas height)
79
+ * @param {number} depth - Distance in front of the camera (camera-space z)
80
+ * @returns {number} World units per pixel
81
+ */
82
+ export function worldPerPixel(scene, depth) {
83
+ const fov = scene.camera.fov ?? DEFAULT_FOV;
84
+ return (depth * 2 * Math.tan(fov / 2)) / scene.canvas.height;
85
+ }
@@ -0,0 +1,62 @@
1
+ import { pick } from "./helpers.js";
2
+
3
+ /**
4
+ * Changes an object's color while the pointer is over it.
5
+ *
6
+ * The object's own `color` is put back when the pointer leaves it,
7
+ * when it leaves the canvas, or when the tool is removed. Objects
8
+ * with a `material` get their fill changed too.
9
+ * Set `obj.hoverable = false` to make an object ignore this tool.
10
+ */
11
+ export class HoverHighlight {
12
+ /**
13
+ * @param {Object} [opts]
14
+ * @param {string} [opts.color="#ffffff"] - Highlight color
15
+ * @param {(obj:Object) => boolean} [opts.filter] - Which objects can be highlighted
16
+ * (default: those without `hoverable: false`)
17
+ * @param {(obj:Object|null) => void} [opts.onChange] - Called when the hovered object changes
18
+ */
19
+ constructor({ color = "#ffffff", filter = obj => obj.hoverable !== false, onChange } = {}) {
20
+ this.color = color;
21
+ this.filter = filter;
22
+ this.onChange = onChange;
23
+
24
+ /** The object under the pointer, or null. */
25
+ this.hovered = null;
26
+ this._savedColor = undefined;
27
+ this._savedMaterial = undefined;
28
+ }
29
+
30
+ pointerMove(e, scene) {
31
+ this._set(pick(scene, e.x, e.y, { filter: this.filter }));
32
+ }
33
+
34
+ pointerLeave() {
35
+ this._set(null);
36
+ }
37
+
38
+ detach() {
39
+ this._set(null);
40
+ }
41
+
42
+ _set(obj) {
43
+ if (obj === this.hovered) return;
44
+
45
+ if (this.hovered) {
46
+ if (this._savedColor === undefined) delete this.hovered.color;
47
+ else this.hovered.color = this._savedColor;
48
+ if (this._savedMaterial) this.hovered.material = this._savedMaterial;
49
+ }
50
+
51
+ this.hovered = obj;
52
+ if (obj) {
53
+ this._savedColor = obj.color;
54
+ obj.color = this.color;
55
+ // Swap in a copy, so other objects sharing the material don't change
56
+ this._savedMaterial = obj.material;
57
+ if (obj.material) obj.material = { ...obj.material, fill: this.color };
58
+ }
59
+
60
+ this.onChange?.(obj);
61
+ }
62
+ }
@@ -0,0 +1,8 @@
1
+ export { ToolManager } from "./manager.js";
2
+ export { pick, screenBounds, worldPerPixel } from "./helpers.js";
3
+ export { OrbitCamera } from "./orbitCamera.js";
4
+ export { DragObject } from "./dragObject.js";
5
+ export { FlyCamera } from "./flyCamera.js";
6
+ export { HoverHighlight } from "./hoverHighlight.js";
7
+ export { FpsMeter } from "./fpsMeter.js";
8
+ export { ObjUploader } from "./objUploader.js";