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.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/package.json +41 -4
- package/trid/index.js +53 -0
- package/trid/loaders/obj.js +206 -0
- package/trid/loaders/texture.js +56 -0
- package/trid/math/projection.js +49 -0
- package/trid/math/transform.js +302 -0
- package/trid/render/canvasRenderer.js +634 -0
- package/trid/render/color.js +22 -0
- package/trid/render/draw.js +146 -0
- package/trid/render/effects.js +265 -0
- package/trid/render/loop.js +42 -0
- package/trid/render/normals.js +90 -0
- package/trid/render/shapes.js +230 -0
- package/trid/render/webglRenderer.js +613 -0
- package/trid/scene.js +167 -0
- package/trid/tools/dragObject.js +89 -0
- package/trid/tools/flyCamera.js +111 -0
- package/trid/tools/fpsMeter.js +51 -0
- package/trid/tools/helpers.js +85 -0
- package/trid/tools/hoverHighlight.js +62 -0
- package/trid/tools/index.js +8 -0
- package/trid/tools/manager.js +241 -0
- package/trid/tools/objUploader.js +153 -0
- package/trid/tools/orbitCamera.js +132 -0
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";
|