trid3d 0.3.0 → 0.4.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/README.md +43 -15
- package/package.json +1 -1
- package/trid/index.d.ts +247 -10
- package/trid/index.js +8 -1
- package/trid/render/canvasRenderer.js +99 -48
- package/trid/render/effects.js +16 -9
- package/trid/render/grid.js +177 -0
- package/trid/render/lights.js +162 -0
- package/trid/render/post.js +325 -0
- package/trid/render/webglRenderer.js +341 -19
- package/trid/scene.js +48 -6
- package/trid/tools/helpers.js +107 -9
- package/trid/tools/index.js +1 -1
- package/trid/tools/transformGizmo.js +40 -6
package/trid/tools/helpers.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { worldToCamera, cameraToScreen, DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
-
import { transformPoint } from "../math/matrix.js";
|
|
2
|
+
import { transformPoint, determinantMat4 } from "../math/matrix.js";
|
|
3
3
|
import { worldMatrix, worldObjects, ancestors } from "../sceneGraph.js";
|
|
4
4
|
import { edgeSegments, wireEdges } from "../render/draw.js";
|
|
5
|
+
import { materialsOf } from "../render/canvasRenderer.js";
|
|
6
|
+
import { newell } from "../render/normals.js";
|
|
7
|
+
import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether picking can find an object: not when it, or one of its groups,
|
|
11
|
+
* has `pickable: false` (helpers like a floor grid).
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} obj
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
export function isPickable(obj) {
|
|
17
|
+
for (let o = obj; o; o = o.parent) if (o.pickable === false) return false;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
/**
|
|
7
22
|
* Screen-space bounding box of an object, in canvas pixels.
|
|
@@ -51,6 +66,7 @@ export function screenBounds(scene, obj) {
|
|
|
51
66
|
*
|
|
52
67
|
* Objects inside groups are found too: the result is the object with the
|
|
53
68
|
* mesh that was hit (use `pickHit(...).path`, or `obj.parent`, for its groups).
|
|
69
|
+
* Objects with `pickable: false` (or inside a group with it) are never found.
|
|
54
70
|
*
|
|
55
71
|
* @param {import("../scene.js").Scene} scene - Scene to search
|
|
56
72
|
* @param {number} x - Canvas X in pixels
|
|
@@ -69,7 +85,7 @@ export function pick(scene, x, y, { filter = () => true, padding = 4, exact = fa
|
|
|
69
85
|
let bestDepth = Infinity;
|
|
70
86
|
|
|
71
87
|
for (const obj of worldObjects(scene)) {
|
|
72
|
-
if (!obj.mesh || !filter(obj)) continue;
|
|
88
|
+
if (!obj.mesh || !isPickable(obj) || !filter(obj)) continue;
|
|
73
89
|
|
|
74
90
|
const depth = worldToCamera(transformPoint(obj.world, { x: 0, y: 0, z: 0 }), scene.camera).z;
|
|
75
91
|
if (depth < near || depth >= bestDepth) continue;
|
|
@@ -94,6 +110,11 @@ export function pick(scene, x, y, { filter = () => true, padding = 4, exact = fa
|
|
|
94
110
|
* Objects without faces (wireframes) are hit within `padding` pixels of
|
|
95
111
|
* one of their lines.
|
|
96
112
|
*
|
|
113
|
+
* The hit also says where on the face: the point (in the world and in the
|
|
114
|
+
* mesh's own space), the triangle and its barycentric weights, the face's
|
|
115
|
+
* normal, and the texture coordinates there (for painting on a model).
|
|
116
|
+
*
|
|
117
|
+
* Objects with `pickable: false` (or inside a group with it) are skipped.
|
|
97
118
|
* Faces with a corner behind the camera's near plane are skipped. The cost
|
|
98
119
|
* is one pass over the objects' vertices per call: fine for clicks and hover
|
|
99
120
|
* on models of a few thousand faces.
|
|
@@ -101,7 +122,7 @@ export function pick(scene, x, y, { filter = () => true, padding = 4, exact = fa
|
|
|
101
122
|
* @example
|
|
102
123
|
* canvas.addEventListener("click", (e) => {
|
|
103
124
|
* const hit = pickHit(scene, e.offsetX * canvas.width / canvas.clientWidth, e.offsetY * canvas.height / canvas.clientHeight);
|
|
104
|
-
* if (hit) console.log(hit.object.name, "face", hit.face);
|
|
125
|
+
* if (hit) console.log(hit.object.name, "face", hit.face, "at", hit.point, "uv", hit.uv);
|
|
105
126
|
* });
|
|
106
127
|
*
|
|
107
128
|
* @param {import("../scene.js").Scene} scene - Scene to search
|
|
@@ -111,7 +132,26 @@ export function pick(scene, x, y, { filter = () => true, padding = 4, exact = fa
|
|
|
111
132
|
* @param {(obj:Object) => boolean} [opts.filter] - Only consider objects that pass
|
|
112
133
|
* @param {number} [opts.padding=4] - How many pixels from a line count as a hit (objects without faces)
|
|
113
134
|
* @param {Object[]} [opts.objects=scene.objects] - Objects to test (with their children)
|
|
114
|
-
* @returns {
|
|
135
|
+
* @returns {PickHit | null} null if nothing is hit
|
|
136
|
+
*
|
|
137
|
+
* @typedef {Object} PickHit
|
|
138
|
+
* @property {Object} object - The object with the mesh that was hit
|
|
139
|
+
* @property {number} face - Index in its `mesh.faces`, or -1 for a line of a wireframe
|
|
140
|
+
* @property {number} depth - Distance in front of the camera (camera-space z)
|
|
141
|
+
* @property {Object[]} path - The object, then its groups up to the top: `[part, group, ..., root]`
|
|
142
|
+
* @property {{x,y,z}} point - Where the hit is, in the world
|
|
143
|
+
* @property {{x,y,z}} local - Where the hit is, in the mesh's own space (`mesh.vertices` space)
|
|
144
|
+
* @property {number[]|null} triangle - The 3 vertex indices of the triangle hit (a face with
|
|
145
|
+
* more corners is cut into a fan from its first corner); null for a line
|
|
146
|
+
* @property {number[]|null} barycentric - Weights of those 3 vertices at the hit (they add up
|
|
147
|
+
* to 1; `local` = their weighted sum); null for a line
|
|
148
|
+
* @property {{x,y,z}|null} normal - Unit normal of the face hit, in the world, on its front
|
|
149
|
+
* side; null for a line
|
|
150
|
+
* @property {{u,v}|null} uv - Texture coordinates at the hit (the face material's `mapping`,
|
|
151
|
+
* before `textureRepeat` / `textureOffset` / `textureRotation`); null without any
|
|
152
|
+
* @property {{u,v}|null} textureUV - Where the texture is read at the hit: `uv` with the
|
|
153
|
+
* material's repeat, offset and rotation (can be outside 0..1 when it repeats; image
|
|
154
|
+
* pixel = `(fract(u) × width, (1 - fract(v)) × height)`); null without texture coordinates
|
|
115
155
|
* `object` has the mesh that was hit, `face` is the index in its
|
|
116
156
|
* `mesh.faces` (-1 for a line), `depth` the distance in front of the
|
|
117
157
|
* camera, `path` the object then its groups up to the top
|
|
@@ -124,7 +164,7 @@ export function pickHit(scene, x, y, { filter = () => true, padding = 4, objects
|
|
|
124
164
|
let best = null;
|
|
125
165
|
|
|
126
166
|
for (const object of worldObjects(objects)) {
|
|
127
|
-
if (!object.mesh?.vertices || !filter(object)) continue;
|
|
167
|
+
if (!object.mesh?.vertices || !isPickable(object) || !filter(object)) continue;
|
|
128
168
|
const mesh = object.mesh;
|
|
129
169
|
const cam = mesh.vertices.map((v) => worldToCamera(transformPoint(object.world, v), camera));
|
|
130
170
|
const scr = cam.map((p) => (p.z > near ? cameraToScreen(p, camera, w, h) : null));
|
|
@@ -141,7 +181,11 @@ export function pickHit(scene, x, y, { filter = () => true, padding = 4, objects
|
|
|
141
181
|
if (!bary) continue;
|
|
142
182
|
// Depth blended by 1/z, like perspective does (exact on tilted faces)
|
|
143
183
|
const depth = 1 / (bary[0] / cam[ia].z + bary[1] / cam[ib].z + bary[2] / cam[ic].z);
|
|
144
|
-
if (closer(depth))
|
|
184
|
+
if (closer(depth)) {
|
|
185
|
+
// Screen weights → weights on the triangle itself (perspective-correct)
|
|
186
|
+
const w = [bary[0] / cam[ia].z * depth, bary[1] / cam[ib].z * depth, bary[2] / cam[ic].z * depth];
|
|
187
|
+
best = { object, face, depth, triangle: [ia, ib, ic], corners: [0, i, i + 1], barycentric: w };
|
|
188
|
+
}
|
|
145
189
|
}
|
|
146
190
|
});
|
|
147
191
|
} else {
|
|
@@ -152,12 +196,66 @@ export function pickHit(scene, x, y, { filter = () => true, padding = 4, objects
|
|
|
152
196
|
const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy || 1)));
|
|
153
197
|
if (Math.hypot(x - (a.x + t * dx), y - (a.y + t * dy)) > padding) continue;
|
|
154
198
|
const depth = 1 / ((1 - t) / cam[i].z + t / cam[j].z);
|
|
155
|
-
if (closer(depth)) best = { object, face: -1, depth };
|
|
199
|
+
if (closer(depth)) best = { object, face: -1, depth, segment: [i, j], along: (t / cam[j].z) * depth };
|
|
156
200
|
}
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
|
-
|
|
160
|
-
|
|
203
|
+
return best ? describeHit(best) : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fills in where on the face (or line) a `pickHit` hit is. */
|
|
207
|
+
function describeHit(h) {
|
|
208
|
+
const { object, face } = h;
|
|
209
|
+
const mesh = object.mesh;
|
|
210
|
+
const v = mesh.vertices;
|
|
211
|
+
const out = {
|
|
212
|
+
object, face, depth: h.depth, path: ancestors(object),
|
|
213
|
+
point: null, local: null, triangle: null, barycentric: null, normal: null, uv: null, textureUV: null,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (face < 0) {
|
|
217
|
+
const [i, j] = h.segment, t = h.along;
|
|
218
|
+
out.local = lerp(v[i], v[j], t);
|
|
219
|
+
out.point = transformPoint(object.world, out.local);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const [a, b, c] = h.triangle, w = h.barycentric;
|
|
224
|
+
out.triangle = h.triangle;
|
|
225
|
+
out.barycentric = w;
|
|
226
|
+
out.local = {
|
|
227
|
+
x: w[0] * v[a].x + w[1] * v[b].x + w[2] * v[c].x,
|
|
228
|
+
y: w[0] * v[a].y + w[1] * v[b].y + w[2] * v[c].y,
|
|
229
|
+
z: w[0] * v[a].z + w[1] * v[b].z + w[2] * v[c].z,
|
|
230
|
+
};
|
|
231
|
+
out.point = transformPoint(object.world, out.local);
|
|
232
|
+
|
|
233
|
+
// The face's normal in the world; a mirrored object reverses its winding
|
|
234
|
+
const f = mesh.faces[face];
|
|
235
|
+
const n = newell(f.map((i) => transformPoint(object.world, v[i])));
|
|
236
|
+
const k = (determinantMat4(object.world) < 0 ? -1 : 1) / (Math.hypot(n.x, n.y, n.z) || 1);
|
|
237
|
+
out.normal = { x: n.x * k, y: n.y * k, z: n.z * k };
|
|
238
|
+
|
|
239
|
+
// Texture coordinates, from the face's material (its mapping, repeat, offset...)
|
|
240
|
+
const mats = materialsOf(object);
|
|
241
|
+
let m = mesh.faceMaterials?.[face] ?? 0;
|
|
242
|
+
if (mats && !(m >= 0 && m < mats.length && mats[m])) m = 0;
|
|
243
|
+
const mat = mats ? mats[m] : null;
|
|
244
|
+
const { uvs, faceUVs } = uvsFor(mesh, mat?.mapping);
|
|
245
|
+
const ft = faceUVs?.[face];
|
|
246
|
+
const ts = uvs && ft ? h.corners.map((corner) => uvs[ft[corner]]) : null;
|
|
247
|
+
if (ts && ts.every(Boolean)) {
|
|
248
|
+
out.uv = {
|
|
249
|
+
u: w[0] * ts[0].u + w[1] * ts[1].u + w[2] * ts[2].u,
|
|
250
|
+
v: w[0] * ts[0].v + w[1] * ts[1].v + w[2] * ts[2].v,
|
|
251
|
+
};
|
|
252
|
+
out.textureUV = applyUVTransform(mat ? uvTransform(mat) : null, out.uv);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function lerp(p, q, t) {
|
|
258
|
+
return { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t };
|
|
161
259
|
}
|
|
162
260
|
|
|
163
261
|
/** Barycentric weights of (x, y) in triangle abc, or null if outside (or degenerate). */
|
package/trid/tools/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { ToolManager } from "./manager.js";
|
|
2
|
-
export { pick, pickHit, screenBounds, worldPerPixel } from "./helpers.js";
|
|
2
|
+
export { pick, pickHit, isPickable, screenBounds, worldPerPixel } from "./helpers.js";
|
|
3
3
|
export { OrbitCamera } from "./orbitCamera.js";
|
|
4
4
|
export { DragObject } from "./dragObject.js";
|
|
5
5
|
export { FlyCamera } from "./flyCamera.js";
|
|
@@ -2,7 +2,7 @@ import { cameraForward, worldToCamera, worldToScreen, DEFAULT_NEAR } from "../ma
|
|
|
2
2
|
import { applyTransformOp, matFromAxisAngle, mulVec } from "../math/rotation.js";
|
|
3
3
|
import { meshBounds } from "../render/meshUtils.js";
|
|
4
4
|
import { transformOpToLocal } from "../math/matrix.js";
|
|
5
|
-
import { worldMatrix, ancestors } from "../sceneGraph.js";
|
|
5
|
+
import { worldMatrix, worldPosition, ancestors } from "../sceneGraph.js";
|
|
6
6
|
import { worldPerPixel } from "./helpers.js";
|
|
7
7
|
|
|
8
8
|
const AXES = [
|
|
@@ -16,8 +16,9 @@ const UNIFORM = 3;
|
|
|
16
16
|
const RING = 64;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Move, rotate and scale handles on one or more objects, around
|
|
20
|
-
*
|
|
19
|
+
* Move, rotate and scale handles on one or more objects, around a pivot:
|
|
20
|
+
* by default the middle of their bounding box (see `pivot`). One tool,
|
|
21
|
+
* three modes:
|
|
21
22
|
*
|
|
22
23
|
* - `"move"`: X, Y, Z arrows; drag one to move along that world axis
|
|
23
24
|
* - `"rotate"`: X, Y, Z rings; drag along a ring to turn around that axis.
|
|
@@ -34,6 +35,16 @@ const RING = 64;
|
|
|
34
35
|
* `apply: false` it only reports what the drag does (a `TransformOp`,
|
|
35
36
|
* applied with `applyTransformOp`) so an app can apply it to its own data.
|
|
36
37
|
*
|
|
38
|
+
* The pivot is where the handles sit and what rotate and scale turn and
|
|
39
|
+
* grow around (it's the ops' `center`):
|
|
40
|
+
* - `"bounds"` (default): the middle of the targets' bounding box
|
|
41
|
+
* - `"origin"`: the targets' origins (their `pos`, in the world; the average
|
|
42
|
+
* of them for several targets)
|
|
43
|
+
* - a point `{ x, y, z }` in the world. It moves with the targets when the
|
|
44
|
+
* gizmo moves them, so the handles stay on it
|
|
45
|
+
* - a function `(targets) => point | null`, asked every frame (for a pivot
|
|
46
|
+
* an app keeps in its own data)
|
|
47
|
+
*
|
|
37
48
|
* Add it **after** the camera tool: tools added later get clicks first, so
|
|
38
49
|
* grabbing a handle doesn't also orbit. Clicks that miss the handles fall
|
|
39
50
|
* through to the tools below.
|
|
@@ -51,15 +62,18 @@ export class TransformGizmo {
|
|
|
51
62
|
* @param {Object[]} [opts.targets=[]] - Objects it edits (change `targets` at any time)
|
|
52
63
|
* @param {number} [opts.size=70] - Handle length in CSS pixels (same on screen at any distance)
|
|
53
64
|
* @param {boolean} [opts.apply=true] - Change the targets' transforms while dragging
|
|
65
|
+
* @param {"bounds"|"origin"|{x:number,y:number,z:number}|((targets:Object[]) => ({x:number,y:number,z:number}|null))} [opts.pivot="bounds"]
|
|
66
|
+
* Where the handles sit and what rotate / scale go around (change `pivot` at any time)
|
|
54
67
|
* @param {(targets:Object[]) => void} [opts.onGrab] - A handle was grabbed
|
|
55
68
|
* @param {(op:Object, targets:Object[]) => void} [opts.onChange] - While dragging: the operation since the grab
|
|
56
69
|
* @param {(op:Object, targets:Object[]) => void} [opts.onDrop] - On release: the whole operation
|
|
57
70
|
*/
|
|
58
|
-
constructor({ mode = "move", targets = [], size = 70, apply = true, onGrab, onChange, onDrop } = {}) {
|
|
71
|
+
constructor({ mode = "move", targets = [], size = 70, apply = true, pivot = "bounds", onGrab, onChange, onDrop } = {}) {
|
|
59
72
|
this.mode = mode;
|
|
60
73
|
this.targets = targets;
|
|
61
74
|
this.size = size;
|
|
62
75
|
this.apply = apply;
|
|
76
|
+
this.pivot = pivot;
|
|
63
77
|
this.onGrab = onGrab;
|
|
64
78
|
this.onChange = onChange;
|
|
65
79
|
this.onDrop = onDrop;
|
|
@@ -74,9 +88,22 @@ export class TransformGizmo {
|
|
|
74
88
|
this._cursor = false; // whether this tool set the canvas cursor
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
/**
|
|
91
|
+
/** The pivot in world space (see `pivot`), or null when there are no targets. */
|
|
78
92
|
center() {
|
|
79
|
-
|
|
93
|
+
if (!this.targets.length) return null;
|
|
94
|
+
const p = this.pivot;
|
|
95
|
+
if (typeof p === "function") return p(this.targets) ?? null;
|
|
96
|
+
if (p && typeof p === "object") return { x: p.x, y: p.y, z: p.z };
|
|
97
|
+
if (p === "origin") {
|
|
98
|
+
const sum = { x: 0, y: 0, z: 0 };
|
|
99
|
+
for (const t of this.targets) {
|
|
100
|
+
const o = worldPosition(t);
|
|
101
|
+
sum.x += o.x; sum.y += o.y; sum.z += o.z;
|
|
102
|
+
}
|
|
103
|
+
const n = this.targets.length;
|
|
104
|
+
return { x: sum.x / n, y: sum.y / n, z: sum.z / n };
|
|
105
|
+
}
|
|
106
|
+
return meshBounds(this.targets)?.center ?? null;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/** Length of the handles in world units at a point, so they keep the same size on screen. */
|
|
@@ -186,6 +213,8 @@ export class TransformGizmo {
|
|
|
186
213
|
parents: this.targets.map((t) => (t.parent ? worldMatrix(t.parent) : null)),
|
|
187
214
|
// A target inside another target already moves with it
|
|
188
215
|
moves: this.targets.map((t) => !ancestors(t).slice(1).some((a) => this.targets.includes(a))),
|
|
216
|
+
// A fixed pivot point, to move along with the targets
|
|
217
|
+
pivot: this.pivot && typeof this.pivot === "object" ? { ...this.pivot } : null,
|
|
189
218
|
op: null,
|
|
190
219
|
};
|
|
191
220
|
this.dragging = true;
|
|
@@ -227,6 +256,11 @@ export class TransformGizmo {
|
|
|
227
256
|
op = { mode: "scale", axis: AXES[d.id].dir, factor: Math.max(0.05, 1 + along()), center: d.center };
|
|
228
257
|
}
|
|
229
258
|
d.op = op;
|
|
259
|
+
if (d.pivot && op.mode === "move" && this.pivot && typeof this.pivot === "object") {
|
|
260
|
+
this.pivot.x = d.pivot.x + op.delta.x;
|
|
261
|
+
this.pivot.y = d.pivot.y + op.delta.y;
|
|
262
|
+
this.pivot.z = d.pivot.z + op.delta.z;
|
|
263
|
+
}
|
|
230
264
|
if (this.apply) {
|
|
231
265
|
this.targets.forEach((t, i) => {
|
|
232
266
|
if (!d.moves[i]) return;
|