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.
@@ -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 {{ object:Object, face:number, depth:number, path:Object[] } | null}
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)) best = { object, face, 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
- if (best) best.path = ancestors(best.object);
160
- return best;
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). */
@@ -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 their
20
- * shared center (the middle of their bounding box). One tool, three modes:
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
- /** Middle of the targets' bounding box in world space, or null. */
91
+ /** The pivot in world space (see `pivot`), or null when there are no targets. */
78
92
  center() {
79
- return this.targets.length ? meshBounds(this.targets)?.center ?? null : null;
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;