@nakednous/host 0.0.3 → 0.0.5

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/dist/index.js CHANGED
@@ -1,3288 +1,4 @@
1
- /**
2
- * @file Core constants — zero dependencies.
3
- * @module tree/constants
4
- * @license AGPL-3.0-only
5
- */
6
-
7
- // Coordinate spaces
8
- const WORLD = 'WORLD';
9
- const EYE = 'EYE';
10
- const NDC = 'NDC';
11
- const SCREEN = 'SCREEN';
12
- const MATRIX = 'MATRIX';
13
-
14
- // Integrator frame source (helm `from` only): the construct's own current pose
15
- // — body-relative; not a general mapDirection space.
16
- const SELF = 'SELF';
17
-
18
- // NDC Z convention (only difference between backends)
19
- const WEBGL = -1; // z ∈ [−1, 1]
20
-
21
- // Handle constraint kinds
22
- const SPHERE = 0;
23
- const PLANE = 1;
24
- const AXIS = 2;
25
- const DIAL = 3;
26
-
27
- // Handle report modes
28
- const POINT = 0;
29
- const DIRECTION = 1;
30
-
31
- /** Normalise quaternion in-place. @returns {number[]} out */
32
- const qNormalize = (out) => {
33
- const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
34
- out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
35
- };
36
-
37
- /** Hamilton product out = a * b. Alias-safe: out may be a or b. @returns {number[]} out */
38
- const qMul = (out, a, b) => {
39
- const ax=a[0],ay=a[1],az=a[2],aw=a[3], bx=b[0],by=b[1],bz=b[2],bw=b[3];
40
- out[0]=aw*bx+ax*bw+ay*bz-az*by;
41
- out[1]=aw*by-ax*bz+ay*bw+az*bx;
42
- out[2]=aw*bz+ax*by-ay*bx+az*bw;
43
- out[3]=aw*bw-ax*bx-ay*by-az*bz;
44
- return out;
45
- };
46
-
47
- // =========================================================================
48
- // Interpolation
49
- // =========================================================================
50
-
51
- /** Spherical linear interpolation. @returns {number[]} out */
52
- const qSlerp = (out, a, b, t) => {
53
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
54
- let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
55
- if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
56
- let f0, f1;
57
- if (1-d > 1e-10) {
58
- const th=Math.acos(d), st=Math.sin(th);
59
- f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
60
- } else {
61
- f0=1-t; f1=t;
62
- }
63
- out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
64
- out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
65
- return qNormalize(out);
66
- };
67
-
68
- /**
69
- * Normalised linear interpolation (nlerp).
70
- * Cheaper than slerp; slightly non-constant angular velocity.
71
- * Handles antipodal quats by flipping b when dot < 0.
72
- * @returns {number[]} out
73
- */
74
- const qNlerp = (out, a, b, t) => {
75
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
76
- if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
77
- out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
78
- out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
79
- return qNormalize(out);
80
- };
81
-
82
- /**
83
- * Build a quaternion from axis-angle.
84
- * @param {number[]} out
85
- * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
86
- * @param {number} angle Radians.
87
- * @returns {number[]} out
88
- */
89
- const qFromAxisAngle = (out, ax, ay, az, angle) => {
90
- const half = angle * 0.5;
91
- const s = Math.sin(half);
92
- const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
93
- out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
94
- out[3] = Math.cos(half);
95
- return out;
96
- };
97
-
98
- /**
99
- * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
100
- * `dir` need not be unit. Right = dir × up, up re-orthogonalised as right × dir;
101
- * when `dir` is parallel to `up` the up hint is re-seeded from the world axis
102
- * least aligned with `dir` (the same seed qFromUnitVectors uses), so every
103
- * direction yields a proper rotation.
104
- * @param {number[]} out
105
- * @param {number[]} dir Forward direction [x,y,z].
106
- * @param {number[]} [up] Up vector [x,y,z].
107
- * @returns {number[]} out
108
- */
109
- const qFromLookDir = (out, dir, up) => {
110
- let fx=dir[0],fy=dir[1],fz=dir[2];
111
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
112
- fx/=fl; fy/=fl; fz/=fl;
113
- let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
114
- let rx=fy*uz-fz*uy, ry=fz*ux-fx*uz, rz=fx*uy-fy*ux; // right = dir × up
115
- let rl=Math.sqrt(rx*rx+ry*ry+rz*rz);
116
- if (rl < 1e-8) { // dir ∥ up: re-seed up
117
- const ax=Math.abs(fx), ay=Math.abs(fy), az=Math.abs(fz);
118
- ux=0; uy=0; uz=0;
119
- if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
120
- rx=fy*uz-fz*uy; ry=fz*ux-fx*uz; rz=fx*uy-fy*ux;
121
- rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
122
- }
123
- rx/=rl; ry/=rl; rz/=rl;
124
- ux=ry*fz-rz*fy; uy=rz*fx-rx*fz; uz=rx*fy-ry*fx; // up = right × dir
125
- return qFromRotMat3x3(out, rx,ux,-fx, ry,uy,-fy, rz,uz,-fz); // columns: right, up, back
126
- };
127
-
128
- /**
129
- * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
130
- * @returns {number[]} out (normalised)
131
- */
132
- const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
133
- const tr = m00+m11+m22;
134
- if (tr > 0) {
135
- const s=0.5/Math.sqrt(tr+1);
136
- out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
137
- } else if (m00>m11 && m00>m22) {
138
- const s=2*Math.sqrt(1+m00-m11-m22);
139
- out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
140
- } else if (m11>m22) {
141
- const s=2*Math.sqrt(1+m11-m00-m22);
142
- out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
143
- } else {
144
- const s=2*Math.sqrt(1+m22-m00-m11);
145
- out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
146
- }
147
- return qNormalize(out);
148
- };
149
-
150
- /**
151
- * Write a quaternion into the rotation block of a column-major mat4.
152
- * Translation and perspective rows/cols are set to identity values.
153
- * @param {Float32Array|number[]} out 16-element array.
154
- * @param {number[]} q [x,y,z,w].
155
- * @returns {Float32Array|number[]} out
156
- */
157
- const qToMat4 = (out, q) => {
158
- const x=q[0],y=q[1],z=q[2],w=q[3];
159
- const x2=x+x,y2=y+y,z2=z+z;
160
- const xx=x*x2,xy=x*y2,xz=x*z2,yy=y*y2,yz=y*z2,zz=z*z2,wx=w*x2,wy=w*y2,wz=w*z2;
161
- out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
162
- out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
163
- out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
164
- out[12]=0; out[13]=0; out[14]=0; out[15]=1;
165
- return out;
166
- };
167
-
168
- // Lookat basis scratch — right (0–2), up (3–5), back (6–8): unit, world space.
169
- const _lb = new Float64Array(9);
170
-
171
- /**
172
- * Orthonormal lookat basis into `_lb`: back = eye − center, right = up × back,
173
- * up = back × right. When the view direction is parallel to the up hint the
174
- * hint is re-seeded from the world axis least aligned with it — the seed
175
- * qFromUnitVectors and qFromLookDir use — so the basis is always a rotation.
176
- */
177
- function _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz) {
178
- let zx=ex-cx, zy=ey-cy, zz=ez-cz;
179
- const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
180
- zx/=zl; zy/=zl; zz/=zl;
181
- let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
182
- let xl=Math.sqrt(xx*xx+xy*xy+xz*xz);
183
- if (xl < 1e-8) { // view ∥ up: re-seed up
184
- const ax=Math.abs(zx), ay=Math.abs(zy), az=Math.abs(zz);
185
- ux=0; uy=0; uz=0;
186
- if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
187
- xx=uy*zz-uz*zy; xy=uz*zx-ux*zz; xz=ux*zy-uy*zx;
188
- xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
189
- }
190
- xx/=xl; xy/=xl; xz/=xl;
191
- _lb[0]=xx; _lb[1]=xy; _lb[2]=xz;
192
- _lb[3]=zy*xz-zz*xy; _lb[4]=zz*xx-zx*xz; _lb[5]=zx*xy-zy*xx;
193
- _lb[6]=zx; _lb[7]=zy; _lb[8]=zz;
194
- }
195
-
196
- /**
197
- * View matrix (world→eye) from lookat parameters.
198
- * Camera looks along −Z in eye space; right = normalize(up × (−Z)).
199
- * Cheaper than building the eye matrix and inverting.
200
- *
201
- * @param {Float32Array|number[]} out 16-element destination.
202
- * @param {number} ex,ey,ez Eye (camera) position.
203
- * @param {number} cx,cy,cz Look-at target.
204
- * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
205
- * parallel to the view direction).
206
- */
207
- function mat4View(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
208
- _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
209
- const xx=_lb[0],xy=_lb[1],xz=_lb[2], yx=_lb[3],yy=_lb[4],yz=_lb[5], zx=_lb[6],zy=_lb[7],zz=_lb[8];
210
- out[0]=xx; out[1]=yx; out[2]=zx; out[3]=0;
211
- out[4]=xy; out[5]=yy; out[6]=zy; out[7]=0;
212
- out[8]=xz; out[9]=yz; out[10]=zz; out[11]=0;
213
- out[12]=-(xx*ex+xy*ey+xz*ez);
214
- out[13]=-(yx*ex+yy*ey+yz*ez);
215
- out[14]=-(zx*ex+zy*ey+zz*ez);
216
- out[15]=1;
217
- return out;
218
- }
219
-
220
- /**
221
- * Eye matrix (eye→world) from lookat parameters.
222
- * Transpose of the rotation block + direct translation column.
223
- * Same parameters as mat4View.
224
- *
225
- * @param {Float32Array|number[]} out 16-element destination.
226
- * @param {number} ex,ey,ez Eye position.
227
- * @param {number} cx,cy,cz Look-at target.
228
- * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
229
- * parallel to the view direction).
230
- */
231
- function mat4Eye(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
232
- _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
233
- const xx=_lb[0],xy=_lb[1],xz=_lb[2], yx=_lb[3],yy=_lb[4],yz=_lb[5], zx=_lb[6],zy=_lb[7],zz=_lb[8];
234
- out[0]=xx; out[1]=xy; out[2]=xz; out[3]=0;
235
- out[4]=yx; out[5]=yy; out[6]=yz; out[7]=0;
236
- out[8]=zx; out[9]=zy; out[10]=zz; out[11]=0;
237
- out[12]=ex; out[13]=ey; out[14]=ez; out[15]=1;
238
- return out;
239
- }
240
-
241
- // =========================================================================
242
- // Projection construction
243
- // =========================================================================
244
-
245
- /**
246
- * Orthographic projection matrix.
247
- *
248
- * @param {Float32Array|number[]} out 16-element destination.
249
- * @param {number} left,right,bottom,top Frustum extents.
250
- * @param {number} near,far Clip plane distances (positive).
251
- * @param {number} ndcZMin −1 (WEBGL) or 0 (WEBGPU).
252
- * @param {number} [ndcYSign=1] +1 = NDC y-up (default); −1 = NDC y-down (native Vulkan).
253
- */
254
- function mat4Ortho(out, left, right, bottom, top, near, far, ndcZMin, ndcYSign=1) {
255
- const rl=1/(right-left), tb=1/(top-bottom), fn=1/(far-near);
256
- out[0]=2*rl; out[1]=0; out[2]=0; out[3]=0;
257
- out[4]=0; out[5]=ndcYSign*2*tb; out[6]=0; out[7]=0;
258
- out[8]=0; out[9]=0; out[10]=(ndcZMin-1)*fn; out[11]=0;
259
- out[12]=-(right+left)*rl; out[13]=ndcYSign*(-(top+bottom)*tb);
260
- out[14]=(ndcZMin*far-near)*fn;
261
- out[15]=1;
262
- return out;
263
- }
264
-
265
- /**
266
- * Perspective projection matrix (general / off-centre frustum).
267
- * Symmetric case: left=-right, bottom=-top — derive from fov+aspect in user space:
268
- * top = near * Math.tan(fov / 2); right = top * aspect
269
- * mat4Persp(out, -right, right, -top, top, near, far, ndcZMin)
270
- *
271
- * @param {Float32Array|number[]} out 16-element destination.
272
- * @param {number} left,right,bottom,top Near-plane extents (signed, y-up: top>0, bottom<0).
273
- * @param {number} near,far Clip plane distances (positive).
274
- * @param {number} ndcZMin −1 (WEBGL) or 0 (WEBGPU).
275
- * @param {number} [ndcYSign=1] +1 = NDC y-up (default); −1 = NDC y-down (native Vulkan).
276
- */
277
- function mat4Persp(out, left, right, bottom, top, near, far, ndcZMin, ndcYSign=1) {
278
- const rl=1/(right-left), tb=1/(top-bottom);
279
- out[0]=2*near*rl; out[1]=0; out[2]=0; out[3]=0;
280
- out[4]=0; out[5]=ndcYSign*2*near*tb; out[6]=0; out[7]=0;
281
- out[8]=(right+left)*rl; out[9]=ndcYSign*(top+bottom)*tb;
282
- out[10]=(ndcZMin*near-far)/(far-near);
283
- out[11]=-1;
284
- out[12]=0; out[13]=0;
285
- out[14]=(ndcZMin-1)*far*near/(far-near);
286
- out[15]=0;
287
- return out;
288
- }
289
-
290
- /**
291
- * @file Matrix arithmetic, space-transform dispatch, and projection queries.
292
- * @module tree/query
293
- * @license AGPL-3.0-only
294
- *
295
- * The operative layer — receives matrices and extracts information.
296
- * Contrast with form.js which constructs matrices from specs.
297
- *
298
- * form.js — specs → matrix
299
- * query.js — matrix → information
300
- *
301
- * Storage: column-major Float32Array / ArrayLike<number>.
302
- * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
303
- * Pipeline: clip = P · V · M · v
304
- *
305
- * ── NDC Z convention ──────────────────────────────────────────────────────
306
- * Passed as `ndcZMin` to every space-transform function:
307
- * WEBGL = −1 z ∈ [−1, 1]
308
- * WEBGPU = 0 z ∈ [ 0, 1]
309
- *
310
- * ── NDC Y convention ──────────────────────────────────────────────────────
311
- * Standard (OpenGL / WebGL / WebGPU browser / Three.js / p5v2):
312
- * NDC y-up — y = +1 at top, y = −1 at bottom.
313
- * Native Vulkan: NDC y-down — projections constructed with ndcYSign = −1
314
- * (see form.js). Query functions are convention-agnostic: they work on
315
- * whatever matrices are passed in.
316
- *
317
- * ── Viewport convention ───────────────────────────────────────────────────
318
- * vp = [x, y, w, h] — w and h are SIGNED.
319
- *
320
- * The sign of h encodes the relationship between NDC y and screen y:
321
- * h < 0 (e.g. −canvasH): screen y-DOWN (DOM / p5 mouseX·mouseY / Vulkan surface)
322
- * NDC y=+1 → screen y=0 (top)
323
- * NDC y=−1 → screen y=H (bottom)
324
- * h > 0 (e.g. +canvasH): screen y-UP (OpenGL desktop / WebGL gl_FragCoord)
325
- * NDC y=−1 → screen y=0 (bottom)
326
- * NDC y=+1 → screen y=H (top)
327
- *
328
- * Pass [0, canvasH, canvasW, −canvasH] for p5/DOM coordinates.
329
- * Pass [0, 0, canvasW, canvasH] for WebGL gl_FragCoord / OpenGL bottom-left.
330
- * All helpers use vp[2]/vp[3] signed — no Math.abs — so both conventions
331
- * work automatically without any branching.
332
- *
333
- * All functions follow the out-first, zero-allocation contract.
334
- * Returns null on degeneracy (singular matrix, etc.).
335
- */
336
-
337
-
338
- // ═══════════════════════════════════════════════════════════════════════════
339
- // Mat4 arithmetic
340
- // ═══════════════════════════════════════════════════════════════════════════
341
-
342
- /** out = A · B (column-major) */
343
- function mat4Mul(out, A, B) {
344
- const a0=A[0],a1=A[1],a2=A[2],a3=A[3],
345
- a4=A[4],a5=A[5],a6=A[6],a7=A[7],
346
- a8=A[8],a9=A[9],a10=A[10],a11=A[11],
347
- a12=A[12],a13=A[13],a14=A[14],a15=A[15];
348
- let b0=B[0],b1=B[1],b2=B[2],b3=B[3];
349
- out[0]=a0*b0+a4*b1+a8*b2+a12*b3;
350
- out[1]=a1*b0+a5*b1+a9*b2+a13*b3;
351
- out[2]=a2*b0+a6*b1+a10*b2+a14*b3;
352
- out[3]=a3*b0+a7*b1+a11*b2+a15*b3;
353
- b0=B[4];b1=B[5];b2=B[6];b3=B[7];
354
- out[4]=a0*b0+a4*b1+a8*b2+a12*b3;
355
- out[5]=a1*b0+a5*b1+a9*b2+a13*b3;
356
- out[6]=a2*b0+a6*b1+a10*b2+a14*b3;
357
- out[7]=a3*b0+a7*b1+a11*b2+a15*b3;
358
- b0=B[8];b1=B[9];b2=B[10];b3=B[11];
359
- out[8]=a0*b0+a4*b1+a8*b2+a12*b3;
360
- out[9]=a1*b0+a5*b1+a9*b2+a13*b3;
361
- out[10]=a2*b0+a6*b1+a10*b2+a14*b3;
362
- out[11]=a3*b0+a7*b1+a11*b2+a15*b3;
363
- b0=B[12];b1=B[13];b2=B[14];b3=B[15];
364
- out[12]=a0*b0+a4*b1+a8*b2+a12*b3;
365
- out[13]=a1*b0+a5*b1+a9*b2+a13*b3;
366
- out[14]=a2*b0+a6*b1+a10*b2+a14*b3;
367
- out[15]=a3*b0+a7*b1+a11*b2+a15*b3;
368
- return out;
369
- }
370
-
371
- /** out = inverse(src). Returns null if singular (|det| < 1e-12). */
372
- function mat4Invert(out, src) {
373
- const s0=src[0],s1=src[1],s2=src[2],s3=src[3],
374
- s4=src[4],s5=src[5],s6=src[6],s7=src[7],
375
- s8=src[8],s9=src[9],s10=src[10],s11=src[11],
376
- s12=src[12],s13=src[13],s14=src[14],s15=src[15];
377
- const b0=s0*s5-s1*s4, b1=s0*s6-s2*s4, b2=s0*s7-s3*s4,
378
- b3=s1*s6-s2*s5, b4=s1*s7-s3*s5, b5=s2*s7-s3*s6,
379
- b6=s8*s13-s9*s12, b7=s8*s14-s10*s12, b8=s8*s15-s11*s12,
380
- b9=s9*s14-s10*s13, b10=s9*s15-s11*s13, b11=s10*s15-s11*s14;
381
- let det=b0*b11-b1*b10+b2*b9+b3*b8-b4*b7+b5*b6;
382
- if (Math.abs(det) < 1e-12) return null;
383
- det = 1/det;
384
- out[0]=(s5*b11-s6*b10+s7*b9)*det;
385
- out[1]=(s2*b10-s1*b11-s3*b9)*det;
386
- out[2]=(s13*b5-s14*b4+s15*b3)*det;
387
- out[3]=(s10*b4-s9*b5-s11*b3)*det;
388
- out[4]=(s6*b8-s4*b11-s7*b7)*det;
389
- out[5]=(s0*b11-s2*b8+s3*b7)*det;
390
- out[6]=(s14*b2-s12*b5-s15*b1)*det;
391
- out[7]=(s8*b5-s10*b2+s11*b1)*det;
392
- out[8]=(s4*b10-s5*b8+s7*b6)*det;
393
- out[9]=(s1*b8-s0*b10-s3*b6)*det;
394
- out[10]=(s12*b4-s13*b2+s15*b0)*det;
395
- out[11]=(s9*b2-s8*b4-s11*b0)*det;
396
- out[12]=(s5*b7-s4*b9-s6*b6)*det;
397
- out[13]=(s0*b9-s1*b7+s2*b6)*det;
398
- out[14]=(s13*b1-s12*b3-s14*b0)*det;
399
- out[15]=(s8*b3-s9*b1+s10*b0)*det;
400
- return out;
401
- }
402
-
403
- /** out = mat4 * [x,y,z,1], perspective-divides, writes xyz. */
404
- function mat4MulPoint(out, m, x, y, z) {
405
- const rx=m[0]*x+m[4]*y+m[8]*z+m[12], ry=m[1]*x+m[5]*y+m[9]*z+m[13],
406
- rz=m[2]*x+m[6]*y+m[10]*z+m[14], rw=m[3]*x+m[7]*y+m[11]*z+m[15];
407
- if (rw!==0&&rw!==1) { out[0]=rx/rw; out[1]=ry/rw; out[2]=rz/rw; }
408
- else { out[0]=rx; out[1]=ry; out[2]=rz; }
409
- return out;
410
- }
411
-
412
- // ═══════════════════════════════════════════════════════════════════════════
413
- // Projection queries
414
- // ═══════════════════════════════════════════════════════════════════════════
415
-
416
- /** @returns {boolean} true if orthographic. */
417
- function projIsOrtho(p) { return p[15] !== 0; }
418
-
419
- /**
420
- * Near plane distance.
421
- * @param {ArrayLike<number>} p Projection mat4.
422
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
423
- */
424
- function projNear(p, ndcZMin) {
425
- return p[15]===0 ? p[14]/(p[10]+ndcZMin) : (p[14]-ndcZMin)/p[10];
426
- }
427
-
428
- /**
429
- * Top extent of the near plane in camera space (y_max, positive for standard y-up camera).
430
- * Sign-normalized: returns the larger of the two y boundaries regardless of whether
431
- * the projection was built with ndcYSign = +1 or −1.
432
- */
433
- function projTop(p, ndcZMin) {
434
- return p[15]===1
435
- ? ( Math.sign(p[5]) - p[13]) / p[5] // ortho
436
- : projNear(p,ndcZMin)*(Math.sign(p[5])+p[9])/p[5]; // perspective
437
- }
438
-
439
- /**
440
- * Bottom extent of the near plane in camera space (y_min, negative for standard y-up camera).
441
- */
442
- function projBottom(p, ndcZMin) {
443
- return p[15]===1
444
- ? (-Math.sign(p[5]) - p[13]) / p[5] // ortho
445
- : projNear(p,ndcZMin)*(p[9]-Math.sign(p[5]))/p[5]; // perspective
446
- }
447
-
448
- /** Vertical field of view in radians (perspective only). */
449
- function projFov (p) { return Math.abs(2*Math.atan(1/p[5])); }
450
-
451
- // ═══════════════════════════════════════════════════════════════════════════
452
- // Space transforms — mapLocation / mapDirection
453
- // ═══════════════════════════════════════════════════════════════════════════
454
- //
455
- // Flat dispatch: every from→to pair is a self-contained leaf with only stack
456
- // locals — no reentrancy, no shared state between calls.
457
- //
458
- // Matrices bag `m`:
459
- // mat4Proj Float32Array(16) projection (eye → clip)
460
- // mat4View Float32Array(16) view (world → eye)
461
- // mat4Eye? Float32Array(16) eye (eye → world); caller fills before passing
462
- // mat4PV? Float32Array(16) P · V; caller fills or _ensurePV allocates once
463
- // mat4PVInv? Float32Array(16) inv(P · V); caller fills
464
- // fromFrame? Float32Array(16) MATRIX source frame
465
- // toFrameInv? Float32Array(16) inv(MATRIX dest frame)
466
- //
467
- // Viewport `vp` = [x, y, w, h]:
468
- // Use SIGNED h to encode screen-y direction (see module header).
469
- // Core formula: screen = (ndc*0.5+0.5)*vp[k] + vp[k-2] (k=2 for x, k=3 for y)
470
- // Inverse: ndc = ((screen-vp[k-2])/vp[k])*2 - 1
471
- // Negative vp[3] flips NDC y-up to screen y-down automatically.
472
- //
473
-
474
- // ── Location helpers ─────────────────────────────────────────────────────
475
-
476
- function _worldToScreen(out, px, py, pz, pv, vp, ndcZMin) {
477
- const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12], y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13],
478
- z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14], w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
479
- const xi=(w!==0&&w!==1)?1/w:1;
480
- out[0]=(x*xi*0.5+0.5)*vp[2]+vp[0];
481
- out[1]=(y*xi*0.5+0.5)*vp[3]+vp[1];
482
- out[2]=(z*xi-ndcZMin)/(1-ndcZMin);
483
- return out;
484
- }
485
-
486
- function _screenToWorld(out, sx, sy, sz, ipv, vp, ndcZMin) {
487
- return mat4MulPoint(out, ipv,
488
- ((sx-vp[0])/vp[2])*2-1,
489
- ((sy-vp[1])/vp[3])*2-1,
490
- sz*(1-ndcZMin)+ndcZMin);
491
- }
492
-
493
- function _worldToNDC(out, px, py, pz, pv) {
494
- const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12], y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13],
495
- z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14], w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
496
- const xi=(w!==0&&w!==1)?1/w:1;
497
- out[0]=x*xi; out[1]=y*xi; out[2]=z*xi;
498
- return out;
499
- }
500
-
501
- function _ensurePV(m) {
502
- if (m.mat4PV) return m.mat4PV;
503
- m.mat4PV = new Float32Array(16);
504
- mat4Mul(m.mat4PV, m.mat4Proj, m.mat4View);
505
- return m.mat4PV;
506
- }
507
-
508
- /**
509
- * Map a point between named coordinate spaces.
510
- *
511
- * @param {number[]} out 3-element destination — written and returned.
512
- * @param {number} px,py,pz Input point.
513
- * @param {string} from Source space (WORLD, EYE, SCREEN, NDC, MATRIX).
514
- * @param {string} to Destination space.
515
- * @param {object} m Matrices bag — see module header.
516
- * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
517
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
518
- * @returns {number[]} out
519
- */
520
- function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
521
- if (to===SCREEN) return _worldToScreen(out,px,py,pz,_ensurePV(m),vp,ndcZMin);
522
-
523
- if (to===NDC) return _worldToNDC(out,px,py,pz,_ensurePV(m));
524
-
525
- if (to===EYE) return mat4MulPoint(out,m.mat4View,px,py,pz);
526
- if (to===MATRIX) return mat4MulPoint(out,m.toFrameInv,px,py,pz);
527
-
528
- out[0]=px; out[1]=py; out[2]=pz;
529
- return out;
530
- }
531
-
532
- // ── Direction helpers ────────────────────────────────────────────────────
533
- //
534
- // Directions use only the linear 3×3 block — no translation, no w-divide.
535
- // The signed vp[2]/vp[3] carries the y-convention automatically.
536
- //
537
-
538
- function _applyDir(out, m, dx, dy, dz) {
539
- out[0]=m[0]*dx+m[4]*dy+m[8]*dz;
540
- out[1]=m[1]*dx+m[5]*dy+m[9]*dz;
541
- out[2]=m[2]*dx+m[6]*dy+m[10]*dz;
542
- return out;
543
- }
544
-
545
- function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
546
- const vx=view[0]*dx+view[4]*dy+view[8]*dz,
547
- vy=view[1]*dx+view[5]*dy+view[9]*dz,
548
- vz=view[2]*dx+view[6]*dy+view[10]*dz;
549
- // vpH is signed — negative flips y component automatically.
550
- out[0]=(proj[0]*vx+proj[4]*vy+proj[8]*vz)*vpW*0.5;
551
- out[1]=(proj[1]*vx+proj[5]*vy+proj[9]*vz)*vpH*0.5;
552
- out[2]=(proj[2]*vx+proj[6]*vy+proj[10]*vz)*(1-ndcZMin)*0.5;
553
- return out;
554
- }
555
-
556
- function _screenToWorldDir(out, dx, dy, dz, proj, eye, vpW, vpH, ndcZMin) {
557
- // Inverse of _worldToScreenDir: undo the viewport scale, then the projection's
558
- // upper-triangular 3×3 block (p[8], p[9] carry an off-centre frustum), then
559
- // rotate eye→world. Signed vpW/vpH cancel the y-flip.
560
- const cz = dz/((1-ndcZMin)*0.5)/proj[10];
561
- const cy = (dy/(vpH*0.5) - proj[9]*cz)/proj[5];
562
- const cx = (dx/(vpW*0.5) - proj[8]*cz)/proj[0];
563
- return _applyDir(out, eye, cx, cy, cz);
564
- }
565
-
566
- function _screenToNDCDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
567
- out[0]=dx/(vpW*0.5); out[1]=dy/(vpH*0.5); out[2]=dz/((1-ndcZMin)*0.5);
568
- return out;
569
- }
570
-
571
- function _ndcToScreenDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
572
- out[0]=dx*vpW*0.5; out[1]=dy*vpH*0.5; out[2]=dz*(1-ndcZMin)*0.5;
573
- return out;
574
- }
575
-
576
- /**
577
- * Map a direction between named coordinate spaces.
578
- * Same bag and viewport contract as mapLocation.
579
- *
580
- * @param {number[]} out 3-element destination.
581
- * @param {number} dx,dy,dz Input direction.
582
- * @param {string} from Source space.
583
- * @param {string} to Destination space.
584
- * @param {object} m Matrices bag — see module header.
585
- * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
586
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
587
- * @returns {number[]} out
588
- */
589
- function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
590
- const vpW=vp[2], vpH=vp[3]; // signed — carry y-convention through all helpers
591
-
592
- if (from===EYE && to===WORLD) return _applyDir(out,m.mat4Eye, dx,dy,dz);
593
- if (from===WORLD && to===EYE) return _applyDir(out,m.mat4View,dx,dy,dz);
594
-
595
- if (from===WORLD && to===SCREEN) return _worldToScreenDir(out,dx,dy,dz,m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
596
- if (from===SCREEN && to===WORLD) return _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye, vpW,vpH,ndcZMin);
597
-
598
- if (from===SCREEN && to===NDC) return _screenToNDCDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
599
- if (from===NDC && to===SCREEN) return _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
600
-
601
- if (from===WORLD && to===NDC) {
602
- _worldToScreenDir(out,dx,dy,dz,m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
603
- return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
604
- }
605
- if (from===NDC && to===WORLD) {
606
- _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
607
- return _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
608
- }
609
-
610
- if (from===EYE && to===SCREEN) {
611
- _applyDir(out,m.mat4Eye,dx,dy,dz);
612
- return _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
613
- }
614
- if (from===SCREEN && to===EYE) {
615
- _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
616
- return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
617
- }
618
-
619
- if (from===EYE && to===NDC) {
620
- _applyDir(out,m.mat4Eye,dx,dy,dz);
621
- _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
622
- return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
623
- }
624
- if (from===NDC && to===EYE) {
625
- _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
626
- _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
627
- return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
628
- }
629
-
630
- if (from===MATRIX && to===WORLD) return _applyDir(out,m.fromFrame, dx,dy,dz);
631
- if (from===WORLD && to===MATRIX) return _applyDir(out,m.toFrameInv,dx,dy,dz);
632
-
633
- if (from===MATRIX && to===EYE) {
634
- _applyDir(out,m.fromFrame,dx,dy,dz);
635
- return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
636
- }
637
- if (from===EYE && to===MATRIX) {
638
- _applyDir(out,m.mat4Eye,dx,dy,dz);
639
- return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
640
- }
641
-
642
- if (from===MATRIX && to===SCREEN) {
643
- _applyDir(out,m.fromFrame,dx,dy,dz);
644
- return _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
645
- }
646
- if (from===SCREEN && to===MATRIX) {
647
- _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
648
- return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
649
- }
650
-
651
- if (from===MATRIX && to===NDC) {
652
- _applyDir(out,m.fromFrame,dx,dy,dz);
653
- _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
654
- return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
655
- }
656
- if (from===NDC && to===MATRIX) {
657
- _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
658
- _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
659
- return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
660
- }
661
-
662
- if (from===MATRIX && to===MATRIX) {
663
- _applyDir(out,m.fromFrame,dx,dy,dz);
664
- return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
665
- }
666
-
667
- out[0]=dx; out[1]=dy; out[2]=dz;
668
- return out;
669
- }
670
-
671
- // ═══════════════════════════════════════════════════════════════════════════
672
- // Rays and pointer hits
673
- // ═══════════════════════════════════════════════════════════════════════════
674
-
675
- /**
676
- * Screen point → world ray: origin on the near plane (screen depth 0), unit
677
- * direction toward the far plane (depth 1). Same bag and viewport contract as
678
- * mapLocation; `m.mat4PVInv` must be filled by the caller.
679
- *
680
- * @param {number[]} outO 3-element origin.
681
- * @param {number[]} outD 3-element unit direction.
682
- * @param {number} sx,sy Screen point.
683
- * @param {object} m Matrices bag — see module header.
684
- * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
685
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
686
- * @returns {number[]|null} outD, or null when the bag carries no mat4PVInv
687
- * (singular P·V) or the ray has no length.
688
- */
689
- function unproject(outO, outD, sx, sy, m, vp, ndcZMin) {
690
- const ipv = m.mat4PVInv;
691
- if (!ipv) return null;
692
- _screenToWorld(outO, sx, sy, 0, ipv, vp, ndcZMin);
693
- _screenToWorld(outD, sx, sy, 1, ipv, vp, ndcZMin);
694
- const dx=outD[0]-outO[0], dy=outD[1]-outO[1], dz=outD[2]-outO[2];
695
- const l=Math.sqrt(dx*dx+dy*dy+dz*dz);
696
- if (!(l > 0)) return null;
697
- outD[0]=dx/l; outD[1]=dy/l; outD[2]=dz/l;
698
- return outD;
699
- }
700
-
701
- // ═══════════════════════════════════════════════════════════════════════════
702
- // pixelRatio
703
- // ═══════════════════════════════════════════════════════════════════════════
704
-
705
- /**
706
- * World-units-per-pixel at a given eye-space Z depth.
707
- * @param {ArrayLike<number>} proj Projection mat4.
708
- * @param {number} vpH Viewport height in pixels (positive).
709
- * @param {number} eyeZ Eye-space Z — negative means in front of camera.
710
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
711
- */
712
- function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
713
- return projIsOrtho(proj)
714
- ? Math.abs(projTop(proj,ndcZMin)-projBottom(proj,ndcZMin)) / vpH
715
- : 2*Math.abs(eyeZ)*Math.tan(projFov(proj)/2) / vpH;
716
- }
717
-
718
- /**
719
- * @file Spline math and keyframe animation state machines.
720
- * @module tree/track
721
- * @license AGPL-3.0-only
722
- *
723
- * Quaternion algebra is provided by quat.js; projection matrix construction
724
- * by form.js. Spline helpers (hermiteVec3, lerpVec3) and TRS↔mat4 conversions
725
- * (transformToMat4, mat4ToTransform) live here because they are tightly
726
- * coupled to the PoseTrack keyframe shape.
727
- *
728
- * Zero dependencies on p5, DOM, WebGL, or WebGPU.
729
- *
730
- * ── Exports ──────────────────────────────────────────────────────────────
731
- * Quaternion helpers (re-exported from quat.js)
732
- * qSet qCopy qDot qNormalize qNegate qMul qConjugate qRotateVec3
733
- * qSlerp qNlerp qFromUnitVectors qFromAxisAngle qFromLookDir
734
- * qFromRotMat3x3 qFromMat4 qToMat4 qToAxisAngle
735
- * Spline / vector helpers
736
- * hermiteVec3 lerpVec3
737
- * Transform / mat4 helpers
738
- * transformToMat4 mat4ToTransform
739
- * Tracks
740
- * PoseTrack — { pos, rot, scl } TRS keyframes
741
- * CameraTrack — { eye, center, up, fov?, halfHeight?, near, far }
742
- * lookat keyframes
743
- *
744
- * ── Public path access (all zero-alloc, no cursor side effects) ──────────
745
- * PoseTrack
746
- * samplePos (out) | (out, seg, t) vec3
747
- * mat4Model (out) | (out, seg, t) mat4 — TRS model
748
- * tangents (outIn, outOut, index) vec3 × 2 at keyframe
749
- * eval (out?) TRS object at cursor
750
- *
751
- * CameraTrack
752
- * sampleEye (out) | (out, seg, t) vec3
753
- * sampleCenter (out) | (out, seg, t) vec3
754
- * mat4Eye (out) | (out, seg, t) mat4 — lookat frame
755
- * eyeTangents (outIn, outOut, index) vec3 × 2 at keyframe
756
- * centerTangents (outIn, outOut, index) vec3 × 2 at keyframe
757
- * eval (out?) { eye, center, up,
758
- * fov, halfHeight,
759
- * near, far }
760
- *
761
- * Two arities for the continuous family:
762
- * (out) cursor form — reads track.seg / track.f. Useful when the
763
- * track's own transport is driving the animation.
764
- * (out, seg, t) explicit form — continuous (seg, t) coordinate, no cursor
765
- * side effects. seg ∈ [0, segments−1] integer, t ∈ [0, 1]
766
- * local to that segment. As a convenience, seg === segments
767
- * is accepted and rewritten to (segments−1, 1) — so the
768
- * idiom sampleX(out, i, 0) uniformly addresses keyframe i
769
- * for every i ∈ [0, keyframes.length−1].
770
- *
771
- * All honour the track's interpolation mode (hermite / linear / step) and
772
- * the same stored-tangent → centripetal-CR fallback chain that eval() uses.
773
- *
774
- * `tangents` / `eyeTangents` / `centerTangents` are keyframe-indexed — a
775
- * keyframe's incoming tangent belongs to the previous segment, outgoing to
776
- * the next. At boundary keyframes the missing side mirrors the present one
777
- * so drawing arrows at endpoints always yields a visible vector.
778
- *
779
- * Projection matrices are deliberately not exposed as a track method. Each
780
- * CameraTrack keyframe stores `fov` (perspective) or `halfHeight` (ortho)
781
- * directly on track.keyframes[i] — callers wanting a projection build one
782
- * with the free mat4Persp / mat4Ortho constructors. Animated fov in
783
- * sketches flows through the bridge's camera-binding via eval().fov and
784
- * eval().halfHeight (the bridge calls cam.perspective() / cam.ortho() each
785
- * frame).
786
- *
787
- * ── Class hierarchy ──────────────────────────────────────────────────────
788
- * Track (unexported, never instantiated directly)
789
- * └── PoseTrack (exported)
790
- * └── CameraTrack (exported)
791
- *
792
- * ── Hook architecture ────────────────────────────────────────────────────
793
- * Lib-space hooks (underscore prefix — host layer / UI layer):
794
- * _onActivate / _onDeactivate — fire on playing false→true / true→false.
795
- * _onPlay / _onEnd / _onStop — mirror the user-space hooks.
796
- *
797
- * User-space hooks:
798
- * onPlay : fires in play() on false→true transition.
799
- * onEnd : fires in tick() at natural boundary (once mode only).
800
- * onStop : fires in stop() / reset().
801
- *
802
- * Firing order:
803
- * play() → onPlay → _onPlay → _onActivate
804
- * tick() → onEnd → _onEnd → _onDeactivate
805
- * stop() → onStop → _onStop → _onDeactivate
806
- * reset() → onStop → _onStop → _onDeactivate
807
- *
808
- * ── Loop modes ───────────────────────────────────────────────────────────
809
- * loop:false, bounce:false — play once, stop at end (fires onEnd)
810
- * loop:true, bounce:false — repeat, wrap back to start
811
- * loop:true, bounce:true — bounce forever at boundaries
812
- * loop:false, bounce:true — bounce once: flip at far boundary, stop at origin
813
- *
814
- * ── Playback semantics (rate + _dir) ─────────────────────────────────────
815
- * rate > 0 forward rate < 0 backward rate === 0 frozen
816
- *
817
- * play() is the sole setter of playing = true.
818
- * stop() is the sole setter of playing = false.
819
- * Assigning rate never starts or stops playback.
820
- *
821
- * _dir (internal, ±1) tracks the current bounce travel direction.
822
- * tick() advances by rate * _dir and flips _dir at boundaries.
823
- * _dir is reset to 1 only in reset().
824
- *
825
- * ── One-keyframe behaviour ───────────────────────────────────────────────
826
- * play() with exactly one keyframe snaps eval() to that keyframe without
827
- * setting playing = true and without firing hooks.
828
- */
829
-
830
-
831
- // =========================================================================
832
- // S2 Spline / vector helpers
833
- // =========================================================================
834
-
835
- function _dist3(a, b) {
836
- const dx=a[0]-b[0], dy=a[1]-b[1], dz=a[2]-b[2];
837
- return Math.sqrt(dx*dx+dy*dy+dz*dz);
838
- }
839
-
840
- /**
841
- * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
842
- * @param {number[]} out 3-element result.
843
- * @param {number[]} p0 Segment start.
844
- * @param {number[]} m0 Outgoing tangent at p0.
845
- * @param {number[]} p1 Segment end.
846
- * @param {number[]} m1 Incoming tangent at p1.
847
- * @param {number} t Blend [0, 1].
848
- * @returns {number[]} out
849
- */
850
- const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
851
- const t2=t*t, t3=t2*t;
852
- const h00=2*t3-3*t2+1, h10=t3-2*t2+t, h01=-2*t3+3*t2, h11=t3-t2;
853
- out[0]=h00*p0[0]+h10*m0[0]+h01*p1[0]+h11*m1[0];
854
- out[1]=h00*p0[1]+h10*m0[1]+h01*p1[1]+h11*m1[1];
855
- out[2]=h00*p0[2]+h10*m0[2]+h01*p1[2]+h11*m1[2];
856
- return out;
857
- };
858
-
859
- // Centripetal CR outgoing tangent at p1 for segment p1→p2.
860
- // Signature: (out, p0, p1, p2). Returns tangent AT p1 (the middle point).
861
- const _crTanOut = (out, p0, p1, p2) => {
862
- const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1;
863
- for (let i=0;i<3;i++) out[i]=((p1[i]-p0[i])/dt0-(p2[i]-p0[i])/(dt0+dt1)+(p2[i]-p1[i])/dt1)*dt1;
864
- return out;
865
- };
866
-
867
- // Centripetal CR incoming tangent at p2 for segment p1→p2.
868
- // Signature: (out, p1, p2, p3). Returns tangent AT p2 (the middle point).
869
- const _crTanIn = (out, p1, p2, p3) => {
870
- const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
871
- for (let i=0;i<3;i++) out[i]=((p2[i]-p1[i])/dt1-(p3[i]-p1[i])/(dt1+dt2)+(p3[i]-p2[i])/dt2)*dt1;
872
- return out;
873
- };
874
-
875
- // Module-level scratch — shared across track instances (non-reentrant hot path).
876
- const _m0=[0,0,0], _m1=[0,0,0];
877
- const _trsScratch = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
878
- const _eyeScratch = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
879
-
880
- /**
881
- * Linear interpolation between two vec3s.
882
- */
883
- const lerpVec3 = (out, a, b, t) => {
884
- out[0]=a[0]+t*(b[0]-a[0]);
885
- out[1]=a[1]+t*(b[1]-a[1]);
886
- out[2]=a[2]+t*(b[2]-a[2]);
887
- return out;
888
- };
889
-
890
- // =========================================================================
891
- // S2b Path samplers — shared core
892
- // =========================================================================
893
-
894
- /** @private — sample interpolated vec3 path at (seg, t) into out. */
895
- function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t) {
896
- const n = kfs.length;
897
- if (n === 0) { out[0]=0; out[1]=0; out[2]=0; return out; }
898
- if (n === 1) {
899
- const p = kfs[0][field];
900
- out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
901
- return out;
902
- }
903
- const nSeg = n - 1;
904
- seg = seg | 0;
905
- if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
906
- else if (seg < 0) { seg = 0; t = 0; }
907
- t = _clamp01(t);
908
- const k0 = kfs[seg];
909
- const k1 = kfs[seg + 1];
910
-
911
- if (interp === 'step') {
912
- const p = k0[field];
913
- out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
914
- return out;
915
- }
916
- if (interp === 'linear') {
917
- return lerpVec3(out, k0[field], k1[field], t);
918
- }
919
-
920
- const p0 = seg > 0 ? kfs[seg - 1][field] : k0[field];
921
- const p3 = seg + 2 < n ? kfs[seg + 2][field] : k1[field];
922
- const m0 = k0[tanOutName] != null ? k0[tanOutName]
923
- : k0[tanInName] != null ? k0[tanInName]
924
- : _crTanOut(_m0, p0, k0[field], k1[field]);
925
- const m1 = k1[tanInName] != null ? k1[tanInName]
926
- : k1[tanOutName] != null ? k1[tanOutName]
927
- : _crTanIn(_m1, k0[field], k1[field], p3);
928
- return hermiteVec3(out, k0[field], m0, k1[field], m1, t);
929
- }
930
-
931
- /** @private — write effective in/out tangents at keyframe i. */
932
- function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i) {
933
- const n = kfs.length;
934
- if (n === 0) {
935
- outIn[0]=outIn[1]=outIn[2]=0; outOut[0]=outOut[1]=outOut[2]=0;
936
- return;
937
- }
938
- i = _clampS(i | 0, 0, n - 1);
939
- const ki = kfs[i];
940
- const hasTI = ki[tanInName] != null;
941
- const hasTO = ki[tanOutName] != null;
942
-
943
- if (hasTO) {
944
- outOut[0]=ki[tanOutName][0]; outOut[1]=ki[tanOutName][1]; outOut[2]=ki[tanOutName][2];
945
- } else if (hasTI) {
946
- outOut[0]=ki[tanInName][0]; outOut[1]=ki[tanInName][1]; outOut[2]=ki[tanInName][2];
947
- } else if (i < n - 1) {
948
- const k1 = kfs[i + 1];
949
- const p0 = i > 0 ? kfs[i - 1][field] : ki[field];
950
- _crTanOut(outOut, p0, ki[field], k1[field]);
951
- } else {
952
- outOut[0]=0; outOut[1]=0; outOut[2]=0;
953
- }
954
-
955
- if (hasTI) {
956
- outIn[0]=ki[tanInName][0]; outIn[1]=ki[tanInName][1]; outIn[2]=ki[tanInName][2];
957
- } else if (hasTO) {
958
- outIn[0]=ki[tanOutName][0]; outIn[1]=ki[tanOutName][1]; outIn[2]=ki[tanOutName][2];
959
- } else if (i > 0) {
960
- const k0 = kfs[i - 1];
961
- const p3 = i + 1 < n ? kfs[i + 1][field] : ki[field];
962
- _crTanIn(outIn, k0[field], ki[field], p3);
963
- } else {
964
- outIn[0]=outOut[0]; outIn[1]=outOut[1]; outIn[2]=outOut[2];
965
- }
966
-
967
- if (i === n - 1 && !hasTO && !hasTI) {
968
- outOut[0]=outIn[0]; outOut[1]=outIn[1]; outOut[2]=outIn[2];
969
- }
970
- }
971
-
972
- // =========================================================================
973
- // S3 Transform <-> Mat4
974
- // =========================================================================
975
-
976
- /**
977
- * Write a TRS transform into a column-major mat4.
978
- */
979
- const transformToMat4 = (out, xform) => {
980
- qToMat4(out, xform.rot);
981
- const sx=xform.scl[0], sy=xform.scl[1], sz=xform.scl[2];
982
- out[0]*=sx; out[1]*=sx; out[2]*=sx;
983
- out[4]*=sy; out[5]*=sy; out[6]*=sy;
984
- out[8]*=sz; out[9]*=sz; out[10]*=sz;
985
- out[12]=xform.pos[0]; out[13]=xform.pos[1]; out[14]=xform.pos[2];
986
- return out;
987
- };
988
-
989
- /**
990
- * Decompose a column-major mat4 into a TRS transform.
991
- */
992
- const mat4ToTransform = (out, m) => {
993
- out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
994
- const sx=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
995
- const sy=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
996
- const sz=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
997
- out.scl[0]=sx; out.scl[1]=sy; out.scl[2]=sz;
998
- qFromRotMat3x3(out.rot,
999
- m[0]/sx,m[4]/sy,m[8]/sz,
1000
- m[1]/sx,m[5]/sy,m[9]/sz,
1001
- m[2]/sx,m[6]/sy,m[10]/sz);
1002
- return out;
1003
- };
1004
-
1005
- // =========================================================================
1006
- // S4a Spec parser — PoseTrack
1007
- // =========================================================================
1008
-
1009
- const _isNum$1 = (x) => typeof x === 'number' && Number.isFinite(x);
1010
- const _clamp01 = (x) => x < 0 ? 0 : (x > 1 ? 1 : x);
1011
- const _clampS = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
1012
-
1013
- function _parseVec3(v) {
1014
- if (!v) return null;
1015
- if (ArrayBuffer.isView(v) && v.length >= 3) return [v[0], v[1], v[2]];
1016
- if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2]];
1017
- if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
1018
- return null;
1019
- }
1020
-
1021
- const _EULER_AXES = { X:[1,0,0], Y:[0,1,0], Z:[0,0,1] };
1022
- const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1023
-
1024
- /**
1025
- * Parse any rotation representation into a unit quaternion [x,y,z,w].
1026
- *
1027
- * Accepted forms:
1028
- * [x,y,z,w] — raw quaternion array
1029
- * { axis:[x,y,z], angle } — axis-angle (angle in radians)
1030
- * { dir:[x,y,z], up?:[x,y,z] } — forward direction (−Z) with optional up
1031
- * { mat4Eye: mat4 } — rotation block of an eye matrix
1032
- * { mat3: mat3 } — column-major 3×3 rotation matrix
1033
- * { euler:[rx,ry,rz], order? } — intrinsic Euler (default order: YXZ)
1034
- * { from:[x,y,z], to:[x,y,z] } — shortest-arc rotation between two vectors
1035
- *
1036
- * @param {*} v
1037
- * @returns {number[]|null} [x,y,z,w] or null if unparseable.
1038
- */
1039
- function _parseQuat(v) {
1040
- if (!v) return null;
1041
-
1042
- // [x,y,z,w] — normalised like every other form
1043
- if (Array.isArray(v) && v.length === 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1044
- if (ArrayBuffer.isView(v) && v.length >= 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1045
-
1046
- if (typeof v !== 'object') return null;
1047
-
1048
- // { axis, angle }
1049
- if (v.axis != null && v.angle != null) {
1050
- const ax = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1051
- return qFromAxisAngle([0,0,0,1], ax[0],ax[1],ax[2], v.angle);
1052
- }
1053
-
1054
- // { dir, up? }
1055
- if (v.dir != null) {
1056
- const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1057
- const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1058
- return qFromLookDir([0,0,0,1], d, u);
1059
- }
1060
-
1061
- // { mat4Eye }
1062
- if (v.mat4Eye != null) {
1063
- const m = (ArrayBuffer.isView(v.mat4Eye) || Array.isArray(v.mat4Eye))
1064
- ? v.mat4Eye : (v.mat4Eye.mat4 ?? null);
1065
- if (!m || m.length < 16) return null;
1066
- return qFromRotMat3x3([0,0,0,1], m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1067
- }
1068
-
1069
- // { mat3 }
1070
- if (v.mat3 != null) {
1071
- const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3)) ? v.mat3 : null;
1072
- if (!m || m.length < 9) return null;
1073
- return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1074
- }
1075
-
1076
- // { euler, order? }
1077
- if (v.euler != null) {
1078
- const e = v.euler;
1079
- if (!Array.isArray(e) || e.length < 3) return null;
1080
- const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1081
- const q = [0,0,0,1], s = [0,0,0,1];
1082
- for (let i = 0; i < 3; i++) {
1083
- const ax = _EULER_AXES[order[i]];
1084
- qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
1085
- }
1086
- return q;
1087
- }
1088
-
1089
- // { from, to }
1090
- if (v.from != null && v.to != null) {
1091
- const f = Array.isArray(v.from) ? v.from : [v.from.x||0, v.from.y||0, v.from.z||0];
1092
- const t = Array.isArray(v.to) ? v.to : [v.to.x||0, v.to.y||0, v.to.z||0];
1093
- const fl = Math.sqrt(f[0]*f[0]+f[1]*f[1]+f[2]*f[2]) || 1;
1094
- const tl = Math.sqrt(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]) || 1;
1095
- const fx=f[0]/fl, fy=f[1]/fl, fz=f[2]/fl;
1096
- const tx=t[0]/tl, ty=t[1]/tl, tz=t[2]/tl;
1097
- const dot = fx*tx + fy*ty + fz*tz;
1098
- if (dot >= 1 - 1e-8) return [0,0,0,1];
1099
- if (dot <= -1 + 1e-8) {
1100
- let px=0, py=fz, pz=-fy;
1101
- let pl = Math.sqrt(px*px+py*py+pz*pz);
1102
- if (pl < 1e-8) { px=fy; py=-fx; pz=0; pl = Math.sqrt(px*px+py*py+pz*pz); }
1103
- if (pl < 1e-8) return [0,0,0,1];
1104
- return qFromAxisAngle([0,0,0,1], px/pl,py/pl,pz/pl, Math.PI);
1105
- }
1106
- let ax=fy*tz-fz*ty, ay=fz*tx-fx*tz, az=fx*ty-fy*tx;
1107
- const al = Math.sqrt(ax*ax+ay*ay+az*az) || 1;
1108
- return qFromAxisAngle([0,0,0,1], ax/al,ay/al,az/al,
1109
- Math.acos(Math.max(-1, Math.min(1, dot))));
1110
- }
1111
-
1112
- return null;
1113
- }
1114
-
1115
- /**
1116
- * Parse a PoseTrack keyframe spec into internal form.
1117
- *
1118
- * Accepted forms:
1119
- * { mat4Model }
1120
- * Decompose a column-major mat4 into TRS via mat4ToTransform.
1121
- * Float32Array(16), plain Array, or { mat4 } wrapper.
1122
- *
1123
- * { pos?, rot?, scl?, tanIn?, tanOut? }
1124
- * Explicit TRS. pos and scl are vec3; rot accepts any form from _parseQuat.
1125
- * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1126
- * missing rot defaults to identity.
1127
- * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1128
- *
1129
- * @param {Object} spec
1130
- * @returns {{ pos:number[], rot:number[], scl:number[],
1131
- * tanIn:number[]|null, tanOut:number[]|null } | null}
1132
- */
1133
- function _parseSpec(spec) {
1134
- if (!spec || typeof spec !== 'object') return null;
1135
-
1136
- // { mat4Model } — full TRS decomposition from model matrix
1137
- if (spec.mat4Model != null) {
1138
- const m = (ArrayBuffer.isView(spec.mat4Model) || Array.isArray(spec.mat4Model))
1139
- ? spec.mat4Model : (spec.mat4Model.mat4 ?? null);
1140
- if (!m || m.length < 16) return null;
1141
- const kf = mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1142
- kf.tanIn = null; kf.tanOut = null;
1143
- return kf;
1144
- }
1145
-
1146
- // { pos?, rot?, scl?, tanIn?, tanOut? } — explicit TRS
1147
- const pos = _parseVec3(spec.pos) || [0,0,0];
1148
- const rot = _parseQuat(spec.rot) || [0,0,0,1];
1149
- const scl = _parseVec3(spec.scl) || [1,1,1];
1150
- const tanIn = _parseVec3(spec.tanIn) || null;
1151
- const tanOut = _parseVec3(spec.tanOut) || null;
1152
- return { pos, rot, scl, tanIn, tanOut };
1153
- }
1154
-
1155
- function _sameTransform(a, b) {
1156
- for (let i=0;i<3;i++) if (a.pos[i]!==b.pos[i]||a.scl[i]!==b.scl[i]) return false;
1157
- for (let i=0;i<4;i++) if (a.rot[i]!==b.rot[i]) return false;
1158
- return true;
1159
- }
1160
-
1161
- // =========================================================================
1162
- // S4b Spec parser — CameraTrack
1163
- // =========================================================================
1164
-
1165
- // Lens defaults used when a spec omits near/far. Match the three.js /
1166
- // Bevy conventions and are safe for typical p5 v2 scene scales.
1167
- const _DEFAULT_NEAR = 0.1;
1168
- const _DEFAULT_FAR = 1000;
1169
-
1170
- /**
1171
- * Parse a CameraTrack keyframe spec into internal form.
1172
- *
1173
- * Required: eye (vec3). Everything else is optional.
1174
- * center defaults to [0, 0, 0]
1175
- * up defaults to [0, 1, 0] and is normalised
1176
- * fov vertical fov (radians), perspective only — null if absent
1177
- * halfHeight world-unit half-height of ortho frustum — null if absent
1178
- * near near clip distance (positive) — defaults to 0.1
1179
- * far far clip distance (positive) — defaults to 1000
1180
- * eyeTanIn/Out optional Hermite tangents for the eye path
1181
- * centerTanIn/Out optional Hermite tangents for the center path
1182
- *
1183
- * fov and halfHeight are mutually exclusive (perspective xor ortho) and
1184
- * therefore left nullable; eval() lerps each only when both adjacent
1185
- * keyframes carry a non-null value, passing null through otherwise.
1186
- *
1187
- * near and far are always meaningful regardless of projection type, so
1188
- * they receive real defaults and are linearly interpolated unconditionally.
1189
- *
1190
- * @param {Object} spec
1191
- * @returns {Object|null} Parsed keyframe or null if eye is missing/malformed.
1192
- */
1193
- function _parseCameraSpec(spec) {
1194
- if (!spec || typeof spec !== 'object') return null;
1195
- const eye = _parseVec3(spec.eye);
1196
- if (!eye) return null;
1197
- const center = _parseVec3(spec.center) || [0,0,0];
1198
- const upRaw = spec.up ? _parseVec3(spec.up) : null;
1199
- const up = upRaw || [0,1,0];
1200
- const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1201
- return {
1202
- eye, center,
1203
- up: [up[0]/ul, up[1]/ul, up[2]/ul],
1204
- fov: typeof spec.fov === 'number' ? spec.fov : null,
1205
- halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1206
- near: typeof spec.near === 'number' ? spec.near : _DEFAULT_NEAR,
1207
- far: typeof spec.far === 'number' ? spec.far : _DEFAULT_FAR,
1208
- eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1209
- eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1210
- centerTanIn: _parseVec3(spec.centerTanIn) || null,
1211
- centerTanOut: _parseVec3(spec.centerTanOut)|| null,
1212
- };
1213
- }
1214
-
1215
- function _sameCameraKeyframe(a, b) {
1216
- for (let i=0;i<3;i++) {
1217
- if (a.eye[i]!==b.eye[i]) return false;
1218
- if (a.center[i]!==b.center[i]) return false;
1219
- if (a.up[i]!==b.up[i]) return false;
1220
- }
1221
- if (a.fov !== b.fov) return false;
1222
- if (a.halfHeight !== b.halfHeight) return false;
1223
- if (a.near !== b.near) return false;
1224
- if (a.far !== b.far) return false;
1225
- return true;
1226
- }
1227
-
1228
- // =========================================================================
1229
- // S5 Track — unexported base class (transport machinery only)
1230
- // =========================================================================
1231
-
1232
- class Track {
1233
- constructor() {
1234
- /** @type {Array} Keyframe array — shape depends on subclass. */
1235
- this.keyframes = [];
1236
- /** Whether playback is currently active. @type {boolean} */
1237
- this.playing = false;
1238
- /** Loop at boundaries. @type {boolean} */
1239
- this.loop = false;
1240
- /** Ping-pong bounce at boundaries (independent of loop). @type {boolean} */
1241
- this.bounce = false;
1242
- /** Frames per segment (≥1). @type {number} */
1243
- this.duration = 30;
1244
- /** Current segment index. @type {number} */
1245
- this.seg = 0;
1246
- /** Frame offset within current segment (can be fractional). @type {number} */
1247
- this.f = 0;
1248
-
1249
- // Playback rate (signed: negative = reverse; 0 = frozen).
1250
- this._rate = 1;
1251
- // Current bounce travel direction (±1). Reset to 1 only on reset().
1252
- this._dir = 1;
1253
- // Whether a bounce-once has already flipped direction.
1254
- this._bounced = false;
1255
-
1256
- /** User hook: fires on play() false→true transition. @type {Function|null} */
1257
- this.onPlay = null;
1258
- /** User hook: fires on natural boundary in once mode. @type {Function|null} */
1259
- this.onEnd = null;
1260
- /** User hook: fires on stop() / reset() / end-of-bounce-once. @type {Function|null} */
1261
- this.onStop = null;
1262
-
1263
- // Lib-space hooks (underscore prefix — host layer / UI layer only).
1264
- this._onActivate = null; this._onDeactivate = null;
1265
- this._onPlay = null; this._onEnd = null; this._onStop = null;
1266
- }
1267
-
1268
- /** Playback rate. Signed: negative reverses, 0 freezes. Assigning never starts or stops playback. @type {number} */
1269
- get rate() { return this._rate; }
1270
- set rate(v) { this._rate = (_isNum$1(v)) ? v : 1; }
1271
-
1272
- /** Number of interpolatable segments (keyframes.length − 1, min 0). @type {number} */
1273
- get segments() { return Math.max(0, this.keyframes.length - 1); }
1274
-
1275
- /**
1276
- * @private — resolve cursor (seg, f) into continuous (seg, t).
1277
- * Shared backing of every cursor-form sampler / matrix method.
1278
- */
1279
- _cursorSegT() {
1280
- const nSeg = this.segments;
1281
- const dur = Math.max(1, this.duration | 0);
1282
- const seg = nSeg > 0 ? _clampS(this.seg, 0, nSeg - 1) : 0;
1283
- const t = _clamp01(this.f / dur);
1284
- return [seg, t];
1285
- }
1286
-
1287
- /**
1288
- * Start or update playback. Sole setter of `playing = true`.
1289
- *
1290
- * Fires `onPlay → _onPlay → _onActivate` only on a false→true transition.
1291
- * Zero keyframes: no-op. Exactly one keyframe: snaps eval() to it but does
1292
- * not set `playing` and fires no hooks.
1293
- *
1294
- * @param {number|{duration?:number,loop?:boolean,bounce?:boolean,
1295
- * rate?:number,onPlay?:Function,onEnd?:Function,
1296
- * onStop?:Function}} [rateOrOpts]
1297
- * A bare number is taken as `rate`; an object configures multiple
1298
- * fields in one call.
1299
- * @returns {Track} this
1300
- */
1301
- play(rateOrOpts) {
1302
- if (this.keyframes.length === 0) return this;
1303
- if (this.keyframes.length === 1) { this.seg = 0; this.f = 0; return this; }
1304
-
1305
- if (typeof rateOrOpts === 'number' && Number.isFinite(rateOrOpts)) {
1306
- this._rate = rateOrOpts;
1307
- } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1308
- const o = rateOrOpts;
1309
- if (_isNum$1(o.duration)) this.duration = Math.max(1, o.duration | 0);
1310
- if ('loop' in o) this.loop = !!o.loop;
1311
- if ('bounce' in o) this.bounce = !!o.bounce;
1312
- if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1313
- if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1314
- if (typeof o.onStop === 'function') this.onStop = o.onStop;
1315
- if (_isNum$1(o.rate)) this._rate = o.rate;
1316
- }
1317
-
1318
- const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
1319
- if (this.seg < 0) this.seg = 0;
1320
- if (this.seg >= nSeg) this.seg = nSeg - 1;
1321
- if (this.f < 0) this.f = 0;
1322
- if (this.f > dur) this.f = dur;
1323
-
1324
- const wasPlaying = this.playing;
1325
- this.playing = true;
1326
- if (!wasPlaying) {
1327
- this._bounced = false;
1328
- if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1329
- this._onPlay?.();
1330
- this._onActivate?.();
1331
- }
1332
- return this;
1333
- }
1334
-
1335
- /**
1336
- * Stop playback. Sole setter of `playing = false`. Fires
1337
- * `onStop → _onStop → _onDeactivate` on a true→false transition.
1338
- *
1339
- * @param {boolean} [rewind] If true, seek to the origin end after stopping
1340
- * (0 when playing forward, 1 when playing backward).
1341
- * @returns {Track} this
1342
- */
1343
- stop(rewind) {
1344
- const wasPlaying = this.playing;
1345
- this.playing = false;
1346
- if (wasPlaying) {
1347
- this._bounced = false;
1348
- if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1349
- this._onStop?.();
1350
- this._onDeactivate?.();
1351
- if (rewind && this.keyframes.length > 1) this.seek(this._rate * this._dir < 0 ? 1 : 0);
1352
- }
1353
- return this;
1354
- }
1355
-
1356
- /**
1357
- * Clear all keyframes and stop. Fires stop-side hooks if it was playing.
1358
- * Unlike stop(), this also resets `_dir` to +1.
1359
- * @returns {Track} this
1360
- */
1361
- reset() {
1362
- const wasPlaying = this.playing;
1363
- this.playing = false;
1364
- if (wasPlaying) {
1365
- if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1366
- this._onStop?.();
1367
- this._onDeactivate?.();
1368
- }
1369
- this.keyframes.length = 0;
1370
- this.seg = 0; this.f = 0; this._dir = 1; this._bounced = false;
1371
- return this;
1372
- }
1373
-
1374
- /**
1375
- * Remove the keyframe at `index`. Adjusts the cursor if the removal shrinks
1376
- * the track below the current segment.
1377
- * @param {number} index
1378
- * @returns {boolean} true if removed; false if index was invalid.
1379
- */
1380
- remove(index) {
1381
- if (!_isNum$1(index)) return false;
1382
- const i = index | 0;
1383
- if (i < 0 || i >= this.keyframes.length) return false;
1384
- this.keyframes.splice(i, 1);
1385
- const nSeg = this.segments;
1386
- if (nSeg === 0) { this.seg = 0; this.f = 0; }
1387
- else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1388
- return true;
1389
- }
1390
-
1391
- /**
1392
- * Move the cursor.
1393
- * seek(t) Scrub to normalised position t ∈ [0, 1] across the
1394
- * whole track.
1395
- * seek(t, segIndex) Position within a specific segment — t is local to
1396
- * that segment.
1397
- * Does not affect `playing`.
1398
- *
1399
- * @param {number} t
1400
- * @param {number} [segIndex]
1401
- * @returns {Track} this
1402
- */
1403
- seek(t, segIndex) {
1404
- const nSeg = this.segments;
1405
- if (nSeg === 0) { this.seg = 0; this.f = 0; return this; }
1406
- const dur = Math.max(1, this.duration | 0);
1407
- if (_isNum$1(segIndex)) {
1408
- this.seg = _clampS(segIndex | 0, 0, nSeg - 1);
1409
- this.f = _clamp01(t) * dur;
1410
- } else {
1411
- this._setCursorFromScalar(_clamp01(t) * nSeg * dur);
1412
- }
1413
- return this;
1414
- }
1415
-
1416
- /**
1417
- * Normalised cursor position across the whole track.
1418
- * @returns {number} ∈ [0, 1]; 0 when there are no segments.
1419
- */
1420
- time() {
1421
- const nSeg = this.segments;
1422
- if (nSeg === 0) return 0;
1423
- const dur = Math.max(1, this.duration | 0);
1424
- return _clamp01((this.seg * dur + this.f) / (nSeg * dur));
1425
- }
1426
-
1427
- /**
1428
- * Snapshot of transport state. Allocates a new object per call — intended
1429
- * for UI / debugging, not hot loops.
1430
- * @returns {{keyframes:number, segments:number, seg:number, f:number,
1431
- * playing:boolean, loop:boolean, bounce:boolean, rate:number,
1432
- * duration:number, time:number}}
1433
- */
1434
- info() {
1435
- return {
1436
- keyframes: this.keyframes.length,
1437
- segments: this.segments,
1438
- seg: this.seg,
1439
- f: this.f,
1440
- playing: this.playing,
1441
- loop: this.loop,
1442
- bounce: this.bounce,
1443
- rate: this._rate,
1444
- duration: this.duration,
1445
- time: this.segments > 0 ? this.time() : 0
1446
- };
1447
- }
1448
-
1449
- /**
1450
- * Advance the cursor by `rate * _dir` in frames. Handles loop / bounce /
1451
- * once modes per the table in the module header. Fires `onEnd → _onEnd →
1452
- * _onDeactivate` at a natural boundary in once mode.
1453
- *
1454
- * Intended to be called once per animation frame by the bridge / UI layer.
1455
- * rate === 0 freezes the cursor but keeps `playing` unchanged.
1456
- *
1457
- * @returns {boolean} Current `playing` state after advancing.
1458
- */
1459
- tick() {
1460
- if (!this.playing) return false;
1461
- const nSeg = this.segments;
1462
- if (nSeg === 0) { this.playing = false; this._onDeactivate?.(); return false; }
1463
- if (this._rate === 0) return true;
1464
-
1465
- const dur = Math.max(1, this.duration | 0);
1466
- const total = nSeg * dur;
1467
- const s = _clampS(this.seg * dur + this.f, 0, total);
1468
- const next = s + this._rate * this._dir;
1469
-
1470
- if (this.loop && this.bounce) {
1471
- let pos = next, flips = 0;
1472
- while (pos < 0 || pos > total) {
1473
- if (pos < 0) { pos = -pos; flips++; }
1474
- else { pos = 2 * total - pos; flips++; }
1475
- }
1476
- if (flips & 1) this._dir = -this._dir;
1477
- this._setCursorFromScalar(pos);
1478
- return true;
1479
- }
1480
-
1481
- if (!this.loop && this.bounce) {
1482
- if (next >= total) {
1483
- this._setCursorFromScalar(Math.min(total, 2 * total - next));
1484
- this._dir = -this._dir;
1485
- this._bounced = true;
1486
- return true;
1487
- }
1488
- if (next <= 0) {
1489
- this._setCursorFromScalar(0);
1490
- this.playing = false;
1491
- this._dir = 1; this._bounced = false;
1492
- if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1493
- this._onEnd?.();
1494
- this._onDeactivate?.();
1495
- return false;
1496
- }
1497
- this._setCursorFromScalar(next);
1498
- return true;
1499
- }
1500
-
1501
- if (this.loop) {
1502
- this._setCursorFromScalar(((next % total) + total) % total);
1503
- return true;
1504
- }
1505
-
1506
- if (next <= 0) {
1507
- this._setCursorFromScalar(0);
1508
- this.playing = false;
1509
- if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1510
- this._onEnd?.();
1511
- this._onDeactivate?.();
1512
- return false;
1513
- }
1514
- if (next >= total) {
1515
- this._setCursorFromScalar(total);
1516
- this.playing = false;
1517
- if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1518
- this._onEnd?.();
1519
- this._onDeactivate?.();
1520
- return false;
1521
- }
1522
-
1523
- this._setCursorFromScalar(next);
1524
- return true;
1525
- }
1526
-
1527
- /** @private */
1528
- _setCursorFromScalar(s) {
1529
- const dur = Math.max(1, this.duration | 0);
1530
- const nSeg = this.segments;
1531
- this.seg = Math.floor(s / dur);
1532
- this.f = s - this.seg * dur;
1533
- if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1534
- if (this.seg < 0) { this.seg = 0; this.f = 0; }
1535
- }
1536
- }
1537
-
1538
- // =========================================================================
1539
- // S6 PoseTrack
1540
- // =========================================================================
1541
-
1542
- /**
1543
- * Renderer-agnostic TRS keyframe track.
1544
- *
1545
- * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z],
1546
- * tanIn?:[x,y,z], tanOut?:[x,y,z] }
1547
- *
1548
- * tanIn — incoming position tangent at this keyframe (Hermite mode).
1549
- * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1550
- * When only one is supplied, the other mirrors it at sample time.
1551
- * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1552
- */
1553
- class PoseTrack extends Track {
1554
- constructor() {
1555
- super();
1556
- /**
1557
- * Position interpolation mode.
1558
- * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom
1559
- * tangents when none are stored (default)
1560
- * - 'linear' — lerp
1561
- * - 'step' — snap to k0; useful for discrete state changes
1562
- * @type {'hermite'|'linear'|'step'}
1563
- */
1564
- this.posInterp = 'hermite';
1565
- /**
1566
- * Rotation interpolation mode.
1567
- * - 'slerp' — constant angular velocity (default)
1568
- * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1569
- * - 'step' — snap to k0 quaternion
1570
- * @type {'slerp'|'nlerp'|'step'}
1571
- */
1572
- this.rotInterp = 'slerp';
1573
- }
1574
-
1575
- /**
1576
- * Append one or more keyframes. Adjacent duplicates are skipped by default.
1577
- * Accepts any spec form understood by _parseSpec, or an array of them.
1578
- * @param {Object|Object[]} spec
1579
- * @param {{ deduplicate?: boolean }} [opts]
1580
- */
1581
- add(spec, opts) {
1582
- if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
1583
- const kf = _parseSpec(spec);
1584
- if (!kf) return;
1585
- const dedup = !opts || opts.deduplicate !== false;
1586
- if (dedup && this.keyframes.length > 0) {
1587
- if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1588
- }
1589
- this.keyframes.push(kf);
1590
- }
1591
-
1592
- /**
1593
- * Replace the keyframe at `index`, or append at the end if `index` equals
1594
- * the current keyframe count.
1595
- * @param {number} index
1596
- * @param {Object} spec Any spec form understood by _parseSpec.
1597
- * @returns {boolean} true on success; false for invalid index or spec.
1598
- */
1599
- set(index, spec) {
1600
- if (!_isNum$1(index)) return false;
1601
- const i = index | 0, kf = _parseSpec(spec);
1602
- if (!kf || i < 0 || i > this.keyframes.length) return false;
1603
- if (i === this.keyframes.length) this.keyframes.push(kf);
1604
- else this.keyframes[i] = kf;
1605
- return true;
1606
- }
1607
-
1608
- /**
1609
- * Sample the position path. Zero-alloc, no cursor side effects. Honours
1610
- * posInterp and the stored-tangent → auto-CR fallback chain.
1611
- *
1612
- * Two signatures:
1613
- * samplePos(out) cursor form — reads current seg/f
1614
- * samplePos(out, seg, t) explicit (seg, t), continuous on the path
1615
- *
1616
- * @param {number[]} out 3-element result buffer.
1617
- * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
1618
- * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
1619
- * @returns {number[]} out
1620
- */
1621
- samplePos(out, seg, t) {
1622
- if (arguments.length < 3) [seg, t] = this._cursorSegT();
1623
- return _samplePathCore(out, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
1624
- }
1625
-
1626
- /**
1627
- * Write the TRS pose as a column-major model mat4. Zero-alloc, no cursor
1628
- * side effects.
1629
- *
1630
- * Two signatures:
1631
- * mat4Model(out) cursor form — reads current seg/f
1632
- * mat4Model(out, seg, t) explicit (seg, t), continuous on the path
1633
- *
1634
- * Replaces the previous toMatrix() method.
1635
- *
1636
- * @param {Float32Array|number[]} out 16-element result buffer.
1637
- * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
1638
- * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
1639
- * @returns {Float32Array|number[]} out
1640
- */
1641
- mat4Model(out, seg, t) {
1642
- if (arguments.length < 3) [seg, t] = this._cursorSegT();
1643
- this._sampleTRS(_trsScratch, seg, t);
1644
- return transformToMat4(out, _trsScratch);
1645
- }
1646
-
1647
- /**
1648
- * Effective incoming / outgoing position tangents at keyframe `index`.
1649
- * Stored tanIn/tanOut take precedence; each mirrors the other when only
1650
- * one is stored; else centripetal Catmull-Rom tangents are auto-computed
1651
- * from neighbours. Boundary keyframes mirror across the missing side.
1652
- *
1653
- * @param {number[]} outIn 3-element result — incoming tangent.
1654
- * @param {number[]} outOut 3-element result — outgoing tangent.
1655
- * @param {number} index Keyframe index.
1656
- * @returns {PoseTrack} this
1657
- */
1658
- tangents(outIn, outOut, index) {
1659
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'pos', 'tanIn', 'tanOut', index);
1660
- return this;
1661
- }
1662
-
1663
- /** @private — write interpolated TRS at (seg, t). */
1664
- _sampleTRS(out, seg, t) {
1665
- const n = this.keyframes.length;
1666
- if (n === 0) return out;
1667
- if (n === 1) {
1668
- const k = this.keyframes[0];
1669
- out.pos[0]=k.pos[0]; out.pos[1]=k.pos[1]; out.pos[2]=k.pos[2];
1670
- out.rot[0]=k.rot[0]; out.rot[1]=k.rot[1]; out.rot[2]=k.rot[2]; out.rot[3]=k.rot[3];
1671
- out.scl[0]=k.scl[0]; out.scl[1]=k.scl[1]; out.scl[2]=k.scl[2];
1672
- return out;
1673
- }
1674
- const nSeg = n - 1;
1675
- seg = seg | 0;
1676
- if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1677
- else if (seg < 0) { seg = 0; t = 0; }
1678
- t = _clamp01(t);
1679
- const k0 = this.keyframes[seg];
1680
- const k1 = this.keyframes[seg + 1];
1681
-
1682
- _samplePathCore(out.pos, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
1683
-
1684
- if (this.rotInterp === 'step') {
1685
- out.rot[0]=k0.rot[0]; out.rot[1]=k0.rot[1]; out.rot[2]=k0.rot[2]; out.rot[3]=k0.rot[3];
1686
- } else if (this.rotInterp === 'nlerp') {
1687
- qNlerp(out.rot, k0.rot, k1.rot, t);
1688
- } else {
1689
- qSlerp(out.rot, k0.rot, k1.rot, t);
1690
- }
1691
-
1692
- lerpVec3(out.scl, k0.scl, k1.scl, t);
1693
- return out;
1694
- }
1695
-
1696
- /**
1697
- * Evaluate interpolated TRS pose at current cursor.
1698
- * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1699
- * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1700
- */
1701
- eval(out) {
1702
- out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
1703
- const n = this.keyframes.length;
1704
- if (n === 0) return out;
1705
- if (n === 1) return this._sampleTRS(out, 0, 0);
1706
- const [seg, t] = this._cursorSegT();
1707
- return this._sampleTRS(out, seg, t);
1708
- }
1709
- }
1710
-
1711
- // =========================================================================
1712
- // S7 CameraTrack
1713
- // =========================================================================
1714
-
1715
- /**
1716
- * Lookat camera keyframe track.
1717
- *
1718
- * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z],
1719
- * fov?:number, halfHeight?:number,
1720
- * near:number, far:number,
1721
- * eyeTanIn?:[x,y,z], eyeTanOut?:[x,y,z],
1722
- * centerTanIn?:[x,y,z], centerTanOut?:[x,y,z] }
1723
- *
1724
- * fov — vertical fov (radians) for perspective cameras; null for ortho.
1725
- * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1726
- * Both are optional and nullable because exactly one is meaningful per
1727
- * keyframe (perspective xor ortho). eval() lerps each only when both
1728
- * adjacent keyframes carry a non-null value for that field; mixed or
1729
- * missing entries pass `null` through.
1730
- *
1731
- * near, far — clip plane distances (positive, world units). Always real
1732
- * numbers. Defaults: near = 0.1, far = 1000 (three.js / Bevy convention).
1733
- * Linearly interpolated between keyframes without null-passthrough.
1734
- *
1735
- * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
1736
- * interpolation of the eye and center paths respectively. When absent,
1737
- * centripetal Catmull-Rom tangents are auto-computed at sample time.
1738
- *
1739
- * Missing fields default to: center → [0,0,0], up → [0,1,0],
1740
- * near → 0.1, far → 1000.
1741
- *
1742
- * For matrix-based capture of a camera-like pose use
1743
- * PoseTrack.add({ mat4Model: mat4Eye }) for full TRS fidelity including roll,
1744
- * or a lookat spec here for camera-style interpolation.
1745
- */
1746
- class CameraTrack extends Track {
1747
- constructor() {
1748
- super();
1749
- /**
1750
- * Eye-path interpolation mode.
1751
- * @type {'hermite'|'linear'|'step'}
1752
- */
1753
- this.eyeInterp = 'hermite';
1754
- /**
1755
- * Center-path interpolation mode.
1756
- * @type {'hermite'|'linear'|'step'}
1757
- */
1758
- this.centerInterp = 'linear';
1759
- }
1760
-
1761
- /**
1762
- * Append one or more camera keyframes. Adjacent duplicates are skipped by
1763
- * default.
1764
- * @param {Object|Object[]} spec
1765
- * @param {{ deduplicate?: boolean }} [opts]
1766
- */
1767
- add(spec, opts) {
1768
- if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
1769
- const kf = _parseCameraSpec(spec);
1770
- if (!kf) return;
1771
- const dedup = !opts || opts.deduplicate !== false;
1772
- if (dedup && this.keyframes.length > 0) {
1773
- if (_sameCameraKeyframe(this.keyframes[this.keyframes.length - 1], kf)) return;
1774
- }
1775
- this.keyframes.push(kf);
1776
- }
1777
-
1778
- /**
1779
- * Replace the camera keyframe at `index`, or append at the end if `index`
1780
- * equals the current keyframe count.
1781
- * @param {number} index
1782
- * @param {Object} spec
1783
- * @returns {boolean}
1784
- */
1785
- set(index, spec) {
1786
- if (!_isNum$1(index)) return false;
1787
- const i = index | 0, kf = _parseCameraSpec(spec);
1788
- if (!kf || i < 0 || i > this.keyframes.length) return false;
1789
- if (i === this.keyframes.length) this.keyframes.push(kf);
1790
- else this.keyframes[i] = kf;
1791
- return true;
1792
- }
1793
-
1794
- /**
1795
- * Sample the eye path. Zero-alloc, no cursor side effects.
1796
- *
1797
- * Two signatures:
1798
- * sampleEye(out) cursor form — reads current seg/f
1799
- * sampleEye(out, seg, t) explicit (seg, t), continuous on the path
1800
- *
1801
- * @param {number[]} out
1802
- * @param {number} [seg]
1803
- * @param {number} [t]
1804
- * @returns {number[]} out
1805
- */
1806
- sampleEye(out, seg, t) {
1807
- if (arguments.length < 3) [seg, t] = this._cursorSegT();
1808
- return _samplePathCore(out, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
1809
- }
1810
-
1811
- /**
1812
- * Sample the center path. Zero-alloc, no cursor side effects.
1813
- *
1814
- * Two signatures:
1815
- * sampleCenter(out) cursor form — reads current seg/f
1816
- * sampleCenter(out, seg, t) explicit (seg, t)
1817
- *
1818
- * @param {number[]} out
1819
- * @param {number} [seg]
1820
- * @param {number} [t]
1821
- * @returns {number[]} out
1822
- */
1823
- sampleCenter(out, seg, t) {
1824
- if (arguments.length < 3) [seg, t] = this._cursorSegT();
1825
- return _samplePathCore(out, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
1826
- }
1827
-
1828
- /**
1829
- * Write the interpolated lookat eye matrix as a column-major mat4
1830
- * (eye→world rigid frame). Zero-alloc, no cursor side effects.
1831
- *
1832
- * Two signatures:
1833
- * mat4Eye(out) cursor form — reads current seg/f
1834
- * mat4Eye(out, seg, t) explicit (seg, t), continuous on the path
1835
- *
1836
- * @param {Float32Array|number[]} out 16-element result buffer.
1837
- * @param {number} [seg]
1838
- * @param {number} [t]
1839
- * @returns {Float32Array|number[]} out
1840
- */
1841
- mat4Eye(out, seg, t) {
1842
- if (arguments.length < 3) [seg, t] = this._cursorSegT();
1843
- this._sampleEyePose(_eyeScratch, seg, t);
1844
- const e = _eyeScratch;
1845
- return mat4Eye(out,
1846
- e.eye[0], e.eye[1], e.eye[2],
1847
- e.center[0], e.center[1], e.center[2],
1848
- e.up[0], e.up[1], e.up[2]);
1849
- }
1850
-
1851
- /**
1852
- * Effective in/out eye tangents at keyframe `index`.
1853
- */
1854
- eyeTangents(outIn, outOut, index) {
1855
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'eye', 'eyeTanIn', 'eyeTanOut', index);
1856
- return this;
1857
- }
1858
-
1859
- /**
1860
- * Effective in/out center tangents at keyframe `index`.
1861
- */
1862
- centerTangents(outIn, outOut, index) {
1863
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'center', 'centerTanIn', 'centerTanOut', index);
1864
- return this;
1865
- }
1866
-
1867
- /** @private — write interpolated { eye, center, up } at (seg, t). */
1868
- _sampleEyePose(out, seg, t) {
1869
- const n = this.keyframes.length;
1870
- if (n === 0) return out;
1871
- if (n === 1) {
1872
- const k = this.keyframes[0];
1873
- out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
1874
- out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
1875
- out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
1876
- return out;
1877
- }
1878
- const nSeg = n - 1;
1879
- seg = seg | 0;
1880
- if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1881
- else if (seg < 0) { seg = 0; t = 0; }
1882
- t = _clamp01(t);
1883
- const k0 = this.keyframes[seg];
1884
- const k1 = this.keyframes[seg + 1];
1885
-
1886
- _samplePathCore(out.eye, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
1887
- _samplePathCore(out.center, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
1888
-
1889
- lerpVec3(out.up, k0.up, k1.up, t);
1890
- const ul = Math.sqrt(out.up[0]*out.up[0]+out.up[1]*out.up[1]+out.up[2]*out.up[2]) || 1;
1891
- out.up[0]/=ul; out.up[1]/=ul; out.up[2]/=ul;
1892
- return out;
1893
- }
1894
-
1895
- /**
1896
- * Evaluate interpolated camera pose at current cursor.
1897
- *
1898
- * `fov` / `halfHeight` are lerped only when both adjacent keyframes carry
1899
- * a non-null value; mixed entries pass `null` through so the bridge can
1900
- * leave the projection unchanged.
1901
- *
1902
- * `near` / `far` are always real numbers and are linearly interpolated
1903
- * unconditionally.
1904
- *
1905
- * @param {{ eye:number[], center:number[], up:number[],
1906
- * fov:number|null, halfHeight:number|null,
1907
- * near:number, far:number }} [out]
1908
- * @returns {{ eye:number[], center:number[], up:number[],
1909
- * fov:number|null, halfHeight:number|null,
1910
- * near:number, far:number }} out
1911
- */
1912
- eval(out) {
1913
- out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0],
1914
- fov:null, halfHeight:null,
1915
- near:_DEFAULT_NEAR, far:_DEFAULT_FAR };
1916
- const n = this.keyframes.length;
1917
- if (n === 0) return out;
1918
- if (n === 1) {
1919
- const k = this.keyframes[0];
1920
- this._sampleEyePose(out, 0, 0);
1921
- out.fov = k.fov; out.halfHeight = k.halfHeight;
1922
- out.near = k.near; out.far = k.far;
1923
- return out;
1924
- }
1925
- const [seg, t] = this._cursorSegT();
1926
- const k0 = this.keyframes[seg];
1927
- const k1 = this.keyframes[seg + 1];
1928
-
1929
- this._sampleEyePose(out, seg, t);
1930
-
1931
- out.fov = (k0.fov != null && k1.fov != null)
1932
- ? k0.fov + t * (k1.fov - k0.fov) : (k0.fov ?? k1.fov ?? null);
1933
- out.halfHeight = (k0.halfHeight != null && k1.halfHeight != null)
1934
- ? k0.halfHeight + t * (k1.halfHeight - k0.halfHeight)
1935
- : (k0.halfHeight ?? k1.halfHeight ?? null);
1936
-
1937
- // near / far carry real defaults on every keyframe — always lerp.
1938
- out.near = k0.near + t * (k1.near - k0.near);
1939
- out.far = k0.far + t * (k1.far - k0.far);
1940
-
1941
- return out;
1942
- }
1943
- }
1944
-
1945
- /**
1946
- * @file Constraint solver, canonical handle state, and ray-primitive
1947
- * intersections for interactive manipulators.
1948
- * @module tree/handle
1949
- * @license AGPL-3.0-only
1950
- *
1951
- * The numeric brain of the manipulator handle. Renderer- and frame-agnostic:
1952
- * inputs are plain numbers in ONE working space chosen by the caller (the
1953
- * bridge), and the solver never learns world vs eye — it solves in whatever
1954
- * space the ray and geometry are expressed in. The bridge converts the pointer
1955
- * ray into the working frame before calling solve(), and converts the value
1956
- * back out via mapLocation / mapDirection.
1957
- *
1958
- * Zero dependencies on p5, DOM, WebGL, or WebGPU. Out-first throughout; no
1959
- * allocation in solve() / value(). Vectors are passed as flat scalars (matching
1960
- * form.js), state is held as plain number[] (matching track.js keyframes).
1961
- *
1962
- * ── Storage convention ─────────────────────────────────────────────────────
1963
- * The core distinguishes two value shapes deliberately:
1964
- * mat4 — 16-element ArrayLike, typically Float32Array, because matrices
1965
- * cross the GL boundary (contiguous f32 is what the renderer wants).
1966
- * vec3 / quat — plain number[] (f64), because they are authoring/state values
1967
- * and because the frozen basis-vector constants (i, j, k, ORIGIN)
1968
- * cannot be expressed as frozen typed arrays. Handle state follows
1969
- * the vec3 rule.
1970
- *
1971
- * ── Constraint kinds ───────────────────────────────────────────────────────
1972
- * SPHERE 2-DOF heading on a sphere of radius r about an anchor.
1973
- * Canonical state is a UNIT DIRECTION (renormalised every solve) —
1974
- * no stored Euler angles, so no gimbal degeneracy at the poles.
1975
- * az/el are derived on request via azEl().
1976
- * PLANE 2-DOF point on a fixed plane (anchor + unit normal).
1977
- * AXIS 1-DOF point on a line (anchor + unit dir), scalar t clamped to extent.
1978
- * DIAL 1-DOF angle on a circle of radius r in the plane (anchor, axis).
1979
- * Canonical state is an ACCUMULATED angle θ (multi-turn winding is
1980
- * preserved: each solve applies the smaller signed arc, so θ counts
1981
- * full turns — essential for bind targets like "rotate this 720°").
1982
- * θ=0 sits at the reference direction `zero` (derived from the axis
1983
- * when not supplied); θ grows right-handed about the axis. Near
1984
- * edge-on view (the ray almost parallel to the dial plane, where a
1985
- * plane hit races to infinity — the classic rotate-gizmo failure)
1986
- * the solve falls back to the TANGENT LINE of the circle at the
1987
- * current angle, converting linear drag to dθ: bounded, monotone,
1988
- * and still pure ray geometry.
1989
- *
1990
- * VIEW (the camera-facing free-translate constraint) is NOT a core kind: the
1991
- * bridge implements it with PLANE, feeding a fresh camera-derived normal and
1992
- * anchor each frame. The core stays oblivious to the camera.
1993
- *
1994
- * ── Report modes ───────────────────────────────────────────────────────────
1995
- * DIRECTION value() writes the unit direction: SPHERE → the heading;
1996
- * DIAL → the radial unit at θ.
1997
- * POINT value() writes a position: SPHERE → anchor + dir·radius;
1998
- * DIAL → the point on the circle; PLANE / AXIS → the constrained
1999
- * point.
2000
- *
2001
- * ── State ownership & portability ──────────────────────────────────────────
2002
- * Each Constraint owns its own state; there is no module-level scratch. solve()
2003
- * writes into instance fields, never shared globals. This keeps the type a
2004
- * plain-data state machine: it ports 1:1 to a Rust enum + impl that is
2005
- * `Send + Sync` for free, with `&mut self` on solve() making concurrent
2006
- * mutation a compile error. The binding closure deliberately lives in the
2007
- * bridge, not here — a stored callback would forfeit that guarantee.
2008
- *
2009
- * ── Extension contract ─────────────────────────────────────────────────────
2010
- * A constraint is any object exposing: `kind` (integer discriminant),
2011
- * `solve(ox,oy,oz, dx,dy,dz)`, `value(out, report)`, `seed(x,y,z)`, and
2012
- * optionally `scalar()` / `azEl(out2)` / `aim(ax,ay,az[, zx,zy,zz])` — the
2013
- * basis re-aim seam the bridge's deferred `from` frame drives — and
2014
- * `proxy(ox,oy,oz, dx,dy,dz, radius)`, the analytic pick: the ray parameter
2015
- * at which a ray in the working space meets the grab proxy of `radius`
2016
- * working units, or Infinity (a kind without one gets a sphere at its
2017
- * POINT). The handle controller drives any conforming constraint
2018
- * (lifecycle, frame conversion, bind, hooks, pick); a new kind — 6-DOF, or
2019
- * app-specific — implements this contract here (portable, draw-free) plus a
2020
- * bridge-side locus draw, rather than forking the controller. The classes
2021
- * below are the reference implementation. See handle-design.md §9–§10.
2022
- *
2023
- * ── Conventions ────────────────────────────────────────────────────────────
2024
- * Ray direction `d` is assumed unit (the bridge normalises). Plane / axis
2025
- * normals and directions are normalised at construction. The angular utilities
2026
- * use a right-handed convention: az about +Y, el measured from the XZ plane.
2027
- * solve() does NOT depend on this convention — it stores the hit direction
2028
- * directly; az/el are a readout/authoring convenience only. DIAL's θ is
2029
- * right-handed about its own axis, measured from `zero`.
2030
- */
2031
-
2032
-
2033
- const EPS = 1e-6;
2034
- const TWO_PI$1 = Math.PI * 2;
2035
-
2036
- // Edge-on threshold for DIAL: below this |d·n| the plane hit is ill-conditioned
2037
- // (dθ per pixel diverges) and the solve switches to the tangent-line fallback.
2038
- // ≈ 4.6° of incidence. Tune empirically (handle-experiments/e2).
2039
- const DIAL_EDGE = 0.08;
2040
-
2041
- // =========================================================================
2042
- // H1 Small private helpers
2043
- // =========================================================================
2044
-
2045
- const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
2046
- const _clamp$1 = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
2047
- const _num = (x, d) => _isNum(x) ? x : d;
2048
-
2049
- /** Wrap an angle to (−π, π]. */
2050
- const _wrapPi = (a) => a - TWO_PI$1 * Math.round(a / TWO_PI$1);
2051
-
2052
- /** Parse a vec3 from array / typed array / {x,y,z}. Returns a fresh [x,y,z] or null. */
2053
- function _vec3$1(v) {
2054
- if (!v) return null;
2055
- if (ArrayBuffer.isView(v) && v.length >= 3) return [v[0], v[1], v[2]];
2056
- if (Array.isArray(v) && v.length >= 3) return [v[0], v[1], v[2]];
2057
- if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
2058
- return null;
2059
- }
2060
-
2061
- /** Normalise a vec3 in place; zero-length falls back to the given default axis. */
2062
- function _unit$1(v, dx, dy, dz) {
2063
- const l = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
2064
- if (l < EPS) { v[0]=dx; v[1]=dy; v[2]=dz; return v; }
2065
- v[0]/=l; v[1]/=l; v[2]/=l;
2066
- return v;
2067
- }
2068
-
2069
- /**
2070
- * Orthonormal in-plane basis for a unit normal n, written into ub, vb. Seeds
2071
- * from the world axis least aligned with n so the first cross can't degenerate.
2072
- * (Same derivation the p5 bridge uses for its plane quad — duplicated here
2073
- * because the core cannot depend on the bridge.)
2074
- */
2075
- function _basis$1(n, ub, vb) {
2076
- const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
2077
- let rx = 0, ry = 0, rz = 0;
2078
- if (ax <= ay && ax <= az) rx = 1; else if (ay <= az) ry = 1; else rz = 1;
2079
- ub[0] = ry*n[2] - rz*n[1]; ub[1] = rz*n[0] - rx*n[2]; ub[2] = rx*n[1] - ry*n[0];
2080
- _unit$1(ub, 1, 0, 0);
2081
- vb[0] = n[1]*ub[2] - n[2]*ub[1]; vb[1] = n[2]*ub[0] - n[0]*ub[2]; vb[2] = n[0]*ub[1] - n[1]*ub[0];
2082
- }
2083
-
2084
- // =========================================================================
2085
- // H2 Ray-primitive intersections (pure, out-first, scalar params)
2086
- // =========================================================================
2087
-
2088
- /**
2089
- * Nearest ray–sphere intersection. The ray direction is assumed unit, so the
2090
- * quadratic's leading coefficient is 1. On a miss, writes the closest-approach
2091
- * point projected onto the sphere (so the handle tracks the limb gracefully
2092
- * rather than snapping away).
2093
- *
2094
- * @param {number[]} out 3-element destination (hit point).
2095
- * @param {number} ox,oy,oz Ray origin.
2096
- * @param {number} dx,dy,dz Ray direction (unit).
2097
- * @param {number} cx,cy,cz Sphere centre.
2098
- * @param {number} r Sphere radius.
2099
- * @returns {number} Ray parameter t at the written hit.
2100
- */
2101
- function raySphere(out, ox,oy,oz, dx,dy,dz, cx,cy,cz, r) {
2102
- const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2103
- const b = lx*dx + ly*dy + lz*dz;
2104
- const cc = lx*lx + ly*ly + lz*lz - r*r;
2105
- const disc = b*b - cc;
2106
- let t;
2107
- if (disc >= 0) {
2108
- const s = Math.sqrt(disc);
2109
- t = -b - s;
2110
- if (t < 0) t = -b + s; // origin inside the sphere: take far root
2111
- out[0]=ox+t*dx; out[1]=oy+t*dy; out[2]=oz+t*dz;
2112
- } else {
2113
- t = -b; // closest approach along the ray
2114
- let hx=ox+t*dx-cx, hy=oy+t*dy-cy, hz=oz+t*dz-cz;
2115
- const hl = Math.sqrt(hx*hx + hy*hy + hz*hz) || 1;
2116
- const k = r/hl;
2117
- out[0]=cx+hx*k; out[1]=cy+hy*k; out[2]=cz+hz*k;
2118
- }
2119
- return t;
2120
- }
2121
-
2122
- /**
2123
- * Ray–plane intersection. A near-parallel ray (|d·n| < EPS) returns Infinity
2124
- * and leaves `out` untouched — the caller keeps the previous value.
2125
- *
2126
- * @param {number[]} out 3-element destination (hit point).
2127
- * @param {number} ox,oy,oz Ray origin.
2128
- * @param {number} dx,dy,dz Ray direction (unit).
2129
- * @param {number} px,py,pz A point on the plane.
2130
- * @param {number} nx,ny,nz Plane normal (unit).
2131
- * @returns {number} Ray parameter t, or Infinity if parallel.
2132
- */
2133
- function rayPlane(out, ox,oy,oz, dx,dy,dz, px,py,pz, nx,ny,nz) {
2134
- const den = dx*nx + dy*ny + dz*nz;
2135
- if (den < EPS && den > -EPS) return Infinity;
2136
- const t = ((px-ox)*nx + (py-oy)*ny + (pz-oz)*nz) / den;
2137
- out[0]=ox+t*dx; out[1]=oy+t*dy; out[2]=oz+t*dz;
2138
- return t;
2139
- }
2140
-
2141
- /**
2142
- * Closest point on the infinite line (p, u) to the ray (o, d). Both `d` and
2143
- * `u` are assumed unit. For a ray parallel to the line, projects the ray
2144
- * origin onto the line.
2145
- *
2146
- * @param {number[]} out 3-element destination (closest point on the line).
2147
- * @param {number} ox,oy,oz Ray origin.
2148
- * @param {number} dx,dy,dz Ray direction (unit).
2149
- * @param {number} px,py,pz A point on the line.
2150
- * @param {number} ux,uy,uz Line direction (unit).
2151
- * @returns {number} Signed parameter s along u at the written point (unclamped).
2152
- */
2153
- function rayClosestPointOnAxis(out, ox,oy,oz, dx,dy,dz, px,py,pz, ux,uy,uz) {
2154
- const wx=ox-px, wy=oy-py, wz=oz-pz;
2155
- const b = dx*ux + dy*uy + dz*uz; // d·u
2156
- const dd = dx*wx + dy*wy + dz*wz; // d·w
2157
- const e = ux*wx + uy*wy + uz*wz; // u·w
2158
- const den = 1 - b*b; // (d·d)(u·u) − (d·u)² with d,u unit
2159
- const s = (den > EPS) ? (e - b*dd) / den : e;
2160
- out[0]=px+s*ux; out[1]=py+s*uy; out[2]=pz+s*uz;
2161
- return s;
2162
- }
2163
-
2164
- // =========================================================================
2165
- // H2b Ray-primitive hit tests (pure, no out: the nearest t, or Infinity)
2166
- // =========================================================================
2167
- //
2168
- // Beside the solve primitives above, which always write a point, these are
2169
- // TESTS: they write nothing and return the ray parameter of the nearest hit
2170
- // with t ≥ 0, or Infinity on a miss. A ray whose origin lies inside the
2171
- // primitive hits at its exit, so a press from inside a proxy still grabs.
2172
- // The ray direction is assumed unit.
2173
-
2174
- /**
2175
- * Ray–sphere hit test.
2176
- * @param {number} ox,oy,oz Ray origin.
2177
- * @param {number} dx,dy,dz Ray direction (unit).
2178
- * @param {number} cx,cy,cz Sphere centre.
2179
- * @param {number} r Sphere radius.
2180
- * @returns {number} The nearest t ≥ 0, or Infinity.
2181
- */
2182
- function rayHitSphere(ox,oy,oz, dx,dy,dz, cx,cy,cz, r) {
2183
- const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2184
- const b = lx*dx + ly*dy + lz*dz;
2185
- const cc = lx*lx + ly*ly + lz*lz - r*r;
2186
- const disc = b*b - cc;
2187
- if (disc < 0) return Infinity;
2188
- const s = Math.sqrt(disc);
2189
- let t = -b - s;
2190
- if (t < 0) t = -b + s; // origin inside: the exit
2191
- return t < 0 ? Infinity : t; // both roots behind the origin
2192
- }
2193
-
2194
- // One end cap of a capsule: the sphere at (cx,cy,cz), a root accepted only
2195
- // where the hit's axial coordinate h = wu + t·du lies beyond the segment on
2196
- // that cap's side (h ≤ 0 at A, h ≥ L at B), so the point is on the capsule's
2197
- // surface and not inside its cylinder.
2198
- function _capHit(ox,oy,oz, dx,dy,dz, cx,cy,cz, r, wu, du, lim, endB) {
2199
- const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2200
- const b = lx*dx + ly*dy + lz*dz;
2201
- const cc = lx*lx + ly*ly + lz*lz - r*r;
2202
- const disc = b*b - cc;
2203
- if (disc < 0) return Infinity;
2204
- const s = Math.sqrt(disc);
2205
- let t = -b - s, h = wu + t*du;
2206
- if (t < 0 || (endB ? h < lim : h > lim)) { t = -b + s; h = wu + t*du; }
2207
- return (t >= 0 && (endB ? h >= lim : h <= lim)) ? t : Infinity;
2208
- }
2209
-
2210
- /**
2211
- * Ray–capsule hit test: the segment A→B swept by radius r. The cylinder wall
2212
- * counts within the segment's extent, each end sphere beyond it; the nearest
2213
- * of the three wins. A zero-length segment is the sphere at A.
2214
- * @param {number} ox,oy,oz Ray origin.
2215
- * @param {number} dx,dy,dz Ray direction (unit).
2216
- * @param {number} ax,ay,az Segment start.
2217
- * @param {number} bx,by,bz Segment end.
2218
- * @param {number} r Capsule radius.
2219
- * @returns {number} The nearest t ≥ 0, or Infinity.
2220
- */
2221
- function rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r) {
2222
- let ux=bx-ax, uy=by-ay, uz=bz-az;
2223
- const L = Math.sqrt(ux*ux + uy*uy + uz*uz);
2224
- if (L < EPS) return rayHitSphere(ox,oy,oz, dx,dy,dz, ax,ay,az, r);
2225
- ux/=L; uy/=L; uz/=L;
2226
- const wx=ox-ax, wy=oy-ay, wz=oz-az;
2227
- const du = dx*ux + dy*uy + dz*uz; // d·u
2228
- const wu = wx*ux + wy*uy + wz*uz; // w·u
2229
- let best = Infinity;
2230
- // Cylinder wall: the quadratic in the components perpendicular to u.
2231
- const a = 1 - du*du;
2232
- if (a > EPS) {
2233
- const b = (dx*wx + dy*wy + dz*wz) - du*wu;
2234
- const c = (wx*wx + wy*wy + wz*wz) - wu*wu - r*r;
2235
- const disc = b*b - a*c;
2236
- if (disc >= 0) {
2237
- const s = Math.sqrt(disc);
2238
- let t = (-b - s)/a, h = wu + t*du;
2239
- if (t < 0 || h < 0 || h > L) { t = (-b + s)/a; h = wu + t*du; }
2240
- if (t >= 0 && h >= 0 && h <= L) best = t;
2241
- }
2242
- }
2243
- const ta = _capHit(ox,oy,oz, dx,dy,dz, ax,ay,az, r, wu, du, 0, false);
2244
- if (ta < best) best = ta;
2245
- const tb = _capHit(ox,oy,oz, dx,dy,dz, bx,by,bz, r, wu, du, L, true);
2246
- if (tb < best) best = tb;
2247
- return best;
2248
- }
2249
-
2250
- // Ring scratch — the plane normal and its in-plane basis. Function-local
2251
- // working values, never state: rayHitRing runs to completion.
2252
- const _rn = [0, 0, 1], _r0 = [1, 0, 0], _r1 = [0, 1, 0];
2253
-
2254
- /**
2255
- * Ray–ring hit test: the circle of radius R about c in the plane
2256
- * perpendicular to u, swept by tube radius r — the analytic torus proxy as a
2257
- * capsule chain, the circle polygonised into `detail` segments and each
2258
- * tested with rayHitCapsule. The chain has volume in every direction, so it
2259
- * never degenerates edge-on; the chordal error is R · (1 − cos(π / detail)).
2260
- * @param {number} ox,oy,oz Ray origin.
2261
- * @param {number} dx,dy,dz Ray direction (unit).
2262
- * @param {number} cx,cy,cz Ring centre.
2263
- * @param {number} ux,uy,uz Ring plane normal (normalised here).
2264
- * @param {number} R Ring radius.
2265
- * @param {number} r Tube radius.
2266
- * @param {number} [detail=32] Chain segments (at least 3).
2267
- * @returns {number} The nearest t ≥ 0, or Infinity.
2268
- */
2269
- function rayHitRing(ox,oy,oz, dx,dy,dz, cx,cy,cz, ux,uy,uz, R, r, detail = 32) {
2270
- const n = _isNum(detail) && detail >= 3 ? Math.floor(detail) : 32;
2271
- _rn[0]=ux; _rn[1]=uy; _rn[2]=uz;
2272
- _unit$1(_rn, 0, 0, 1);
2273
- _basis$1(_rn, _r0, _r1);
2274
- const step = TWO_PI$1 / n;
2275
- let best = Infinity;
2276
- let ax = cx + R*_r0[0], ay = cy + R*_r0[1], az = cz + R*_r0[2]; // vertex at angle 0
2277
- for (let i = 1; i <= n; i++) {
2278
- const th = i === n ? 0 : i*step; // close the chain exactly
2279
- const cs = Math.cos(th)*R, sn = Math.sin(th)*R;
2280
- const bx = cx + cs*_r0[0] + sn*_r1[0];
2281
- const by = cy + cs*_r0[1] + sn*_r1[1];
2282
- const bz = cz + cs*_r0[2] + sn*_r1[2];
2283
- const t = rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r);
2284
- if (t < best) best = t;
2285
- ax = bx; ay = by; az = bz;
2286
- }
2287
- return best;
2288
- }
2289
-
2290
- // =========================================================================
2291
- // H3 Angular utilities (readout / authoring convenience)
2292
- // =========================================================================
2293
-
2294
- /**
2295
- * Unit direction from azimuth/elevation (right-handed: az about +Y, el from XZ).
2296
- * @param {number[]} out 3-element destination.
2297
- * @param {number} az Azimuth (radians).
2298
- * @param {number} el Elevation (radians).
2299
- * @returns {number[]} out
2300
- */
2301
- function dirFromAzEl(out, az, el) {
2302
- const ce = Math.cos(el);
2303
- out[0]=ce*Math.cos(az); out[1]=Math.sin(el); out[2]=ce*Math.sin(az);
2304
- return out;
2305
- }
2306
-
2307
- /**
2308
- * Azimuth/elevation from a unit direction. Inverse of dirFromAzEl.
2309
- * @param {number[]} out2 2-element destination [az, el] (radians).
2310
- * @param {number} dx,dy,dz Unit direction.
2311
- * @returns {number[]} out2
2312
- */
2313
- function azElFromDir(out2, dx, dy, dz) {
2314
- out2[0] = Math.atan2(dz, dx); // az
2315
- out2[1] = Math.asin(_clamp$1(dy, -1, 1)); // el
2316
- return out2;
2317
- }
2318
-
2319
- // =========================================================================
2320
- // H4 Constraint — tagged state machine (one class, kind discriminant)
2321
- // =========================================================================
2322
-
2323
- /**
2324
- * A draggable constraint: maps a ray to a constrained value and holds the
2325
- * canonical state between drags. One class with a `kind` discriminant (never
2326
- * per-constraint subclasses) so it maps cleanly to a Rust enum.
2327
- *
2328
- * Construction options (all optional unless noted):
2329
- * SPHERE — { radius = 1, report = DIRECTION, anchor = [0,0,0] }
2330
- * PLANE — { anchor = [0,0,0], normal = [0,1,0] }
2331
- * AXIS — { anchor = [0,0,0], axis = [1,0,0], extent = [-1, 1] }
2332
- * DIAL — { anchor = [0,0,0], axis = [0,1,0], radius = 1, zero?,
2333
- * extent = [-∞, ∞] }
2334
- * `axis` is the dial-plane normal (θ is right-handed about it);
2335
- * `zero` is the in-plane reference direction for θ=0 (projected
2336
- * onto the plane and normalised; derived from the axis when
2337
- * absent); `extent` clamps θ in radians — unbounded by default,
2338
- * so the dial winds multiple turns.
2339
- *
2340
- * @param {number} kind SPHERE | PLANE | AXIS | DIAL.
2341
- * @param {Object} [opts]
2342
- */
2343
- class Constraint {
2344
- constructor(kind, opts = {}) {
2345
- /** Constraint kind discriminant. @type {number} */
2346
- this.kind = kind;
2347
-
2348
- /** Constraint origin (sphere centre / plane point / axis anchor / dial centre). @type {number[]} */
2349
- this.anchor = _vec3$1(opts.anchor) || [0, 0, 0];
2350
- /** Canonical unit direction — SPHERE. @type {number[]} */
2351
- this.dir = [0, 0, 1];
2352
- /** Constrained point — PLANE / AXIS / DIAL (and SPHERE scratch). @type {number[]} */
2353
- this.pt = [this.anchor[0], this.anchor[1], this.anchor[2]];
2354
- /** Plane normal (unit) — PLANE. @type {number[]} */
2355
- this.n = _unit$1(_vec3$1(opts.normal) || [0, 1, 0], 0, 1, 0);
2356
- /** Axis / dial-plane normal (unit) — AXIS / DIAL. @type {number[]} */
2357
- this.u = kind === DIAL
2358
- ? _unit$1(_vec3$1(opts.axis) || [0, 1, 0], 0, 1, 0)
2359
- : _unit$1(_vec3$1(opts.axis) || [1, 0, 0], 1, 0, 0);
2360
- /** Current scalar parameter — AXIS: t along the line; DIAL: accumulated θ. @type {number} */
2361
- this.s = 0;
2362
-
2363
- // DIAL in-plane basis: r0 (the θ=0 reference) and r1 = u × r0, so the
2364
- // point at θ is anchor + radius·(cosθ·r0 + sinθ·r1) — right-handed about u.
2365
- /** θ=0 reference direction (unit, in-plane) — DIAL. @type {number[]} */
2366
- this.r0 = [1, 0, 0];
2367
- /** In-plane binormal u × r0 (unit) — DIAL. @type {number[]} */
2368
- this.r1 = [0, 0, 1];
2369
- if (kind === DIAL) {
2370
- const z = _vec3$1(opts.zero);
2371
- this._dialBasis(z ? z[0] : NaN, z ? z[1] : NaN, z ? z[2] : NaN);
2372
- }
2373
-
2374
- // Extent: AXIS clamps t (default [-1, 1]); DIAL clamps θ in radians
2375
- // (default unbounded, so the dial winds freely).
2376
- const ext = Array.isArray(opts.extent) ? opts.extent : null;
2377
- const dial = kind === DIAL;
2378
- /** Minimum scalar parameter. @type {number} */
2379
- this.min = ext && _isNum(ext[0]) ? ext[0] : (dial ? -Infinity : -1);
2380
- /** Maximum scalar parameter. @type {number} */
2381
- this.max = ext && _isNum(ext[1]) ? ext[1] : (dial ? Infinity : 1);
2382
-
2383
- // Sphere / dial radius (private backing — see radius getter/setter).
2384
- this._radius = _num(opts.radius, 1);
2385
-
2386
- /**
2387
- * Default report mode. SPHERE defaults to DIRECTION; PLANE / AXIS / DIAL
2388
- * report a POINT.
2389
- * @type {number}
2390
- */
2391
- this.report = (opts.report === POINT || opts.report === DIRECTION)
2392
- ? opts.report
2393
- : (kind === SPHERE ? DIRECTION : POINT);
2394
-
2395
- if (kind === DIAL) this._dialPoint();
2396
- }
2397
-
2398
- /** Sphere / dial radius. @type {number} */
2399
- get radius() { return this._radius; }
2400
- set radius(r) { this._radius = _isNum(r) ? r : this._radius; if (this.kind === DIAL) this._dialPoint(); }
2401
-
2402
- // Recompute the DIAL point from θ (anchor + ρ(cosθ·r0 + sinθ·r1)).
2403
- _dialPoint() {
2404
- const c = Math.cos(this.s) * this._radius, sn = Math.sin(this.s) * this._radius;
2405
- this.pt[0] = this.anchor[0] + c*this.r0[0] + sn*this.r1[0];
2406
- this.pt[1] = this.anchor[1] + c*this.r0[1] + sn*this.r1[1];
2407
- this.pt[2] = this.anchor[2] + c*this.r0[2] + sn*this.r1[2];
2408
- }
2409
-
2410
- // Build the DIAL in-plane basis (r0, r1) from the current axis u and an
2411
- // optional θ=0 reference (zx,zy,zz): the reference is projected onto the
2412
- // dial plane and normalised; absent (NaN) or degenerate, r0 derives from u
2413
- // via the least-aligned-axis seed. r1 = u × r0, right-handed about u.
2414
- _dialBasis(zx, zy, zz) {
2415
- if (_isNum(zx) && _isNum(zy) && _isNum(zz)) {
2416
- const d = zx*this.u[0] + zy*this.u[1] + zz*this.u[2];
2417
- this.r0[0] = zx - d*this.u[0];
2418
- this.r0[1] = zy - d*this.u[1];
2419
- this.r0[2] = zz - d*this.u[2];
2420
- const l = Math.sqrt(this.r0[0]**2 + this.r0[1]**2 + this.r0[2]**2);
2421
- if (l < EPS) _basis$1(this.u, this.r0, this.r1);
2422
- else { this.r0[0]/=l; this.r0[1]/=l; this.r0[2]/=l; }
2423
- } else {
2424
- _basis$1(this.u, this.r0, this.r1);
2425
- }
2426
- // r1 = u × r0 (recomputed even when _basis ran — same result, one rule).
2427
- this.r1[0] = this.u[1]*this.r0[2] - this.u[2]*this.r0[1];
2428
- this.r1[1] = this.u[2]*this.r0[0] - this.u[0]*this.r0[2];
2429
- this.r1[2] = this.u[0]*this.r0[1] - this.u[1]*this.r0[0];
2430
- }
2431
-
2432
- /**
2433
- * Update the canonical state from a ray in the working space. The ray
2434
- * direction is assumed unit. Chainable.
2435
- *
2436
- * @param {number} ox,oy,oz Ray origin.
2437
- * @param {number} dx,dy,dz Ray direction (unit).
2438
- * @returns {Constraint} this
2439
- */
2440
- solve(ox, oy, oz, dx, dy, dz) {
2441
- if (this.kind === SPHERE) {
2442
- // Hit the sphere into pt, then derive the unit heading from the anchor.
2443
- raySphere(this.pt, ox,oy,oz, dx,dy,dz,
2444
- this.anchor[0], this.anchor[1], this.anchor[2], this._radius);
2445
- this.dir[0] = this.pt[0] - this.anchor[0];
2446
- this.dir[1] = this.pt[1] - this.anchor[1];
2447
- this.dir[2] = this.pt[2] - this.anchor[2];
2448
- _unit$1(this.dir, this.dir[0], this.dir[1], this.dir[2]);
2449
- } else if (this.kind === PLANE) {
2450
- // Parallel ray returns Infinity and leaves pt unchanged (keep last).
2451
- rayPlane(this.pt, ox,oy,oz, dx,dy,dz,
2452
- this.anchor[0], this.anchor[1], this.anchor[2],
2453
- this.n[0], this.n[1], this.n[2]);
2454
- } else if (this.kind === AXIS) {
2455
- let s = rayClosestPointOnAxis(this.pt, ox,oy,oz, dx,dy,dz,
2456
- this.anchor[0], this.anchor[1], this.anchor[2],
2457
- this.u[0], this.u[1], this.u[2]);
2458
- s = _clamp$1(s, this.min, this.max);
2459
- this.s = s;
2460
- this.pt[0] = this.anchor[0] + s*this.u[0]; // clamped point
2461
- this.pt[1] = this.anchor[1] + s*this.u[1];
2462
- this.pt[2] = this.anchor[2] + s*this.u[2];
2463
- } else if (this.kind === DIAL) {
2464
- this._solveDial(ox, oy, oz, dx, dy, dz);
2465
- }
2466
- return this;
2467
- }
2468
-
2469
- // DIAL solve. Face-on: hit the dial plane, derive the raw angle of the hit
2470
- // about the anchor, and accumulate the smaller signed arc into θ (winding
2471
- // preserved). Edge-on (|d·u| < DIAL_EDGE): the plane hit diverges, so solve
2472
- // the ray against the TANGENT LINE of the circle at the current θ instead —
2473
- // the signed tangent parameter over the radius is dθ, bounded and monotone.
2474
- _solveDial(ox, oy, oz, dx, dy, dz) {
2475
- const u = this.u, r0 = this.r0, r1 = this.r1, a = this.anchor;
2476
- const den = dx*u[0] + dy*u[1] + dz*u[2];
2477
- let dth;
2478
- if (den >= DIAL_EDGE || den <= -DIAL_EDGE) {
2479
- // Well-conditioned: plane hit → raw angle → smaller signed arc.
2480
- const t = rayPlane(this.pt, ox,oy,oz, dx,dy,dz,
2481
- a[0], a[1], a[2], u[0], u[1], u[2]);
2482
- if (t === Infinity) return; // keep last (paranoia; den guards)
2483
- const vx = this.pt[0]-a[0], vy = this.pt[1]-a[1], vz = this.pt[2]-a[2];
2484
- const raw = Math.atan2(vx*r1[0] + vy*r1[1] + vz*r1[2],
2485
- vx*r0[0] + vy*r0[1] + vz*r0[2]);
2486
- dth = _wrapPi(raw - _wrapPi(this.s));
2487
- } else {
2488
- // Edge-on fallback: tangent at the current point, w = −sinθ·r0 + cosθ·r1.
2489
- const cs = Math.cos(this.s), sn = Math.sin(this.s);
2490
- const wx = -sn*r0[0] + cs*r1[0];
2491
- const wy = -sn*r0[1] + cs*r1[1];
2492
- const wz = -sn*r0[2] + cs*r1[2];
2493
- this._dialPoint(); // tangent anchored at the current point
2494
- const sl = rayClosestPointOnAxis(this.pt, ox,oy,oz, dx,dy,dz,
2495
- this.pt[0], this.pt[1], this.pt[2],
2496
- wx, wy, wz);
2497
- dth = sl / (this._radius || 1);
2498
- }
2499
- this.s = _clamp$1(this.s + dth, this.min, this.max);
2500
- this._dialPoint();
2501
- }
2502
-
2503
- /**
2504
- * Write the current value into `out`. `report` overrides the default for
2505
- * this call (e.g. read a SPHERE's point even when its default is DIRECTION).
2506
- * SPHERE+POINT and DIAL+POINT are derived from the angle/heading and the
2507
- * live radius, so they stay correct after a radius change without
2508
- * re-solving. DIAL+DIRECTION writes the radial unit at θ.
2509
- *
2510
- * @param {number[]} out 3-element destination.
2511
- * @param {number} [report] POINT | DIRECTION override.
2512
- * @returns {number[]} out
2513
- */
2514
- value(out, report) {
2515
- const r = (report === POINT || report === DIRECTION) ? report : this.report;
2516
- if (this.kind === SPHERE) {
2517
- if (r === DIRECTION) {
2518
- out[0]=this.dir[0]; out[1]=this.dir[1]; out[2]=this.dir[2];
2519
- } else {
2520
- out[0]=this.anchor[0]+this.dir[0]*this._radius;
2521
- out[1]=this.anchor[1]+this.dir[1]*this._radius;
2522
- out[2]=this.anchor[2]+this.dir[2]*this._radius;
2523
- }
2524
- } else if (this.kind === DIAL) {
2525
- const cs = Math.cos(this.s), sn = Math.sin(this.s);
2526
- if (r === DIRECTION) {
2527
- out[0]=cs*this.r0[0]+sn*this.r1[0];
2528
- out[1]=cs*this.r0[1]+sn*this.r1[1];
2529
- out[2]=cs*this.r0[2]+sn*this.r1[2];
2530
- } else {
2531
- out[0]=this.anchor[0]+(cs*this.r0[0]+sn*this.r1[0])*this._radius;
2532
- out[1]=this.anchor[1]+(cs*this.r0[1]+sn*this.r1[1])*this._radius;
2533
- out[2]=this.anchor[2]+(cs*this.r0[2]+sn*this.r1[2])*this._radius;
2534
- }
2535
- } else {
2536
- out[0]=this.pt[0]; out[1]=this.pt[1]; out[2]=this.pt[2];
2537
- }
2538
- return out;
2539
- }
2540
-
2541
- /**
2542
- * Current scalar parameter: AXIS → signed t along the line; DIAL →
2543
- * accumulated angle θ in radians (multi-turn).
2544
- * @returns {number} The parameter, or NaN for non-scalar constraints.
2545
- */
2546
- scalar() {
2547
- return (this.kind === AXIS || this.kind === DIAL) ? this.s : NaN;
2548
- }
2549
-
2550
- /**
2551
- * Derive [az, el] from the current SPHERE direction (SPHERE only).
2552
- * @param {number[]} out2 2-element destination [az, el].
2553
- * @returns {number[]} out2
2554
- */
2555
- azEl(out2) {
2556
- return azElFromDir(out2, this.dir[0], this.dir[1], this.dir[2]);
2557
- }
2558
-
2559
- /**
2560
- * Seed the canonical state from a value (used by the bridge on bind() so the
2561
- * handle starts at the bound target's value). Chainable.
2562
- * SPHERE — sets the unit direction (a point value recovers its heading).
2563
- * PLANE — projects the value onto the plane.
2564
- * AXIS — projects the value onto the line, clamped to extent.
2565
- * DIAL — projects the value onto the dial plane, derives the wrapped
2566
- * angle, and picks the WINDING NEAREST the current θ (so an
2567
- * external sync doesn't unwind accumulated turns), clamped.
2568
- *
2569
- * @param {number} x,y,z Seed value.
2570
- * @returns {Constraint} this
2571
- */
2572
- seed(x, y, z) {
2573
- if (this.kind === SPHERE) {
2574
- const vx=x-this.anchor[0], vy=y-this.anchor[1], vz=z-this.anchor[2];
2575
- const l = Math.sqrt(vx*vx + vy*vy + vz*vz);
2576
- if (l >= EPS) { this.dir[0]=vx/l; this.dir[1]=vy/l; this.dir[2]=vz/l; }
2577
- } else if (this.kind === PLANE) {
2578
- const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2579
- const d = wx*this.n[0] + wy*this.n[1] + wz*this.n[2];
2580
- this.pt[0]=x-d*this.n[0]; this.pt[1]=y-d*this.n[1]; this.pt[2]=z-d*this.n[2];
2581
- } else if (this.kind === AXIS) {
2582
- const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2583
- const s = _clamp$1(wx*this.u[0] + wy*this.u[1] + wz*this.u[2], this.min, this.max);
2584
- this.s = s;
2585
- this.pt[0]=this.anchor[0]+s*this.u[0];
2586
- this.pt[1]=this.anchor[1]+s*this.u[1];
2587
- this.pt[2]=this.anchor[2]+s*this.u[2];
2588
- } else if (this.kind === DIAL) {
2589
- const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2590
- const px = wx*this.r0[0] + wy*this.r0[1] + wz*this.r0[2];
2591
- const py = wx*this.r1[0] + wy*this.r1[1] + wz*this.r1[2];
2592
- if (px*px + py*py >= EPS*EPS) {
2593
- const a = Math.atan2(py, px);
2594
- // Nearest winding to the current θ preserves accumulated turns.
2595
- this.s = _clamp$1(a + TWO_PI$1 * Math.round((this.s - a) / TWO_PI$1),
2596
- this.min, this.max);
2597
- }
2598
- this._dialPoint();
2599
- }
2600
- return this;
2601
- }
2602
-
2603
- /**
2604
- * Re-aim the constraint basis in the working space — the deferred-frame
2605
- * seam (the p5.tree bridge's `from` opt resolves its symbolic basis through
2606
- * mapDirection and calls this). Per kind:
2607
- * PLANE — new normal; the point is re-projected onto the new plane.
2608
- * AXIS — new direction; the scalar t is preserved, the point recomputed.
2609
- * DIAL — new plane normal + optional θ=0 reference; θ is preserved, the
2610
- * in-plane basis rebuilt (reference re-derived when omitted),
2611
- * the point recomputed.
2612
- * SPHERE — no basis; no-op.
2613
- * Inputs are normalised; a zero-length axis keeps the previous one.
2614
- * Chainable.
2615
- *
2616
- * @param {number} ax,ay,az New normal (PLANE) / direction (AXIS) / dial-plane normal (DIAL).
2617
- * @param {number} [zx,zy,zz] DIAL only — θ=0 reference (re-derived when omitted).
2618
- * @returns {Constraint} this
2619
- */
2620
- aim(ax, ay, az, zx, zy, zz) {
2621
- if (this.kind === PLANE) {
2622
- const px = this.n[0], py = this.n[1], pz = this.n[2];
2623
- this.n[0] = ax; this.n[1] = ay; this.n[2] = az;
2624
- _unit$1(this.n, px, py, pz);
2625
- this.seed(this.pt[0], this.pt[1], this.pt[2]);
2626
- } else if (this.kind === AXIS) {
2627
- const px = this.u[0], py = this.u[1], pz = this.u[2];
2628
- this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
2629
- _unit$1(this.u, px, py, pz);
2630
- this.pt[0] = this.anchor[0] + this.s*this.u[0];
2631
- this.pt[1] = this.anchor[1] + this.s*this.u[1];
2632
- this.pt[2] = this.anchor[2] + this.s*this.u[2];
2633
- } else if (this.kind === DIAL) {
2634
- const px = this.u[0], py = this.u[1], pz = this.u[2];
2635
- this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
2636
- _unit$1(this.u, px, py, pz);
2637
- this._dialBasis(zx, zy, zz);
2638
- this._dialPoint();
2639
- }
2640
- return this;
2641
- }
2642
-
2643
- /**
2644
- * The analytic pick proxy: the ray parameter t at which a ray in the
2645
- * working space meets the grab proxy, or Infinity. `radius` is the grab
2646
- * size in working-space units — a constant pixel size the caller converted
2647
- * through pixelRatio at the proxy's depth. SPHERE / PLANE / AXIS: a sphere
2648
- * of `radius` at the reported POINT. DIAL: the ring at the anchor with
2649
- * tube radius `radius` (rayHitRing), so a grab lands anywhere on the ring.
2650
- *
2651
- * @param {number} ox,oy,oz Ray origin.
2652
- * @param {number} dx,dy,dz Ray direction (unit).
2653
- * @param {number} radius Grab radius, working units.
2654
- * @returns {number} The nearest t ≥ 0, or Infinity.
2655
- */
2656
- proxy(ox, oy, oz, dx, dy, dz, radius) {
2657
- const a = this.anchor;
2658
- if (this.kind === DIAL) {
2659
- return rayHitRing(ox,oy,oz, dx,dy,dz, a[0], a[1], a[2],
2660
- this.u[0], this.u[1], this.u[2], this._radius, radius);
2661
- }
2662
- let px, py, pz;
2663
- if (this.kind === SPHERE) {
2664
- px = a[0] + this.dir[0]*this._radius;
2665
- py = a[1] + this.dir[1]*this._radius;
2666
- pz = a[2] + this.dir[2]*this._radius;
2667
- } else {
2668
- px = this.pt[0]; py = this.pt[1]; pz = this.pt[2];
2669
- }
2670
- return rayHitSphere(ox,oy,oz, dx,dy,dz, px,py,pz, radius);
2671
- }
2672
- }
2673
-
2674
- /**
2675
- * Convenience factory mirroring the constructor. Handy for headless tests and
2676
- * for the bridge, which otherwise calls `new Constraint(...)` directly.
2677
- * @param {number} kind SPHERE | PLANE | AXIS | DIAL.
2678
- * @param {Object} [opts]
2679
- * @returns {Constraint}
2680
- */
2681
- function createConstraint(kind, opts) {
2682
- return new Constraint(kind, opts);
2683
- }
2684
-
2685
- /**
2686
- * @file Source-agnostic 6-DOF pose helm — integrates a live rate stream into a
2687
- * { pos, rot } pose. Renderer- and transport-agnostic. Zero dependencies.
2688
- * @module tree/helm
2689
- * @license AGPL-3.0-only
2690
- *
2691
- * A helm is the rate-stream sibling of the Track family: where a Track produces
2692
- * a pose from keyframes over time, a helm produces a pose from a live 6-DOF
2693
- * delta stream (a SpaceNavigator, a tracked hand, an agent policy). It is NOT a
2694
- * handle — a handle reports a `vec3` solved from a pointer ray; a helm reports a
2695
- * pose (position + orientation).
2696
- *
2697
- * Family placement (peer of PoseTrack, NOT a Track subclass — no timeline):
2698
- * feed(translation, rotation) push the latest raw device rate (input, as Track.add)
2699
- * step(out, dt, basis) integrate the rate by dt into the pose (BRIDGE-DRIVEN,
2700
- * as Track.tick) — writes the new pose into out
2701
- * eval(out) read the current { pos, rot } (zero-alloc, as Track.eval)
2702
- * home(pose?) re-home the integrated pose (NOT reset — no keyframes to clear)
2703
- *
2704
- * Value layer (opt-in, §10): a per-helm `fullScale` keeps the read-outs honest
2705
- * across input scales; an opt-in `filter` (oneEuro) conditions the fed rates
2706
- * before the deadzone inside step; `poseDelta` differences two absolute poses
2707
- * into a feedable rate. With `filter` null and the default `fullScale`, the
2708
- * clean rate-native path is unchanged.
2709
- *
2710
- * Quaternion algebra is provided by quat.js. Out-first throughout; no allocation
2711
- * in feed/step/eval. Storage convention (matches the rest of the core): vec3 and
2712
- * quat state are plain number[] (f64) — the same shape as track.js keyframes and
2713
- * the frozen basis-vector constants.
2714
- *
2715
- * ── Frame discipline (`from` / `basis`) ──────────────────────────────────────
2716
- * The space the rates are interpreted in is named by `from` (WORLD | EYE | mat4),
2717
- * a declaration the BRIDGE reads — the core never learns about a camera. The
2718
- * resolved orientation arrives per-step as `basis`: an eye→world mat4 (or null
2719
- * for WORLD ≡ the identity basis). Both the linear and angular rates are rotated
2720
- * through that basis, then the quaternion is composed world-frame. This is the
2721
- * single code path that covers both manipulation conventions:
2722
- *
2723
- * - camera body-fly — basis is the driven camera's own eye matrix (which this
2724
- * helm produced last frame ⇒ equals the integrated `q`,
2725
- * zero staleness); rotating a body delta through `q` then
2726
- * world-composing is algebraically a body-frame compose.
2727
- * - screen-relative — basis is the *viewing* camera's eye matrix (an external
2728
- * frame); a push moves the target relative to the screen.
2729
- *
2730
- * Per-channel `from` ({ translation, rotation }) is a deferred, non-breaking
2731
- * extension (one basis suffices today — pose-helm-design.md §8.3).
2732
- */
2733
-
2734
-
2735
- // =========================================================================
2736
- // Module-level scratch — shared across helm instances (non-reentrant; step
2737
- // is synchronous and called at most once per helm per frame).
2738
- // =========================================================================
2739
-
2740
- const _dq = [0, 0, 0, 1];
2741
-
2742
- // Scratch for the optional input filter (helm.filter). The fed rates are packed
2743
- // into one 6-vector, conditioned, and unpacked to lin/ang triples before the
2744
- // deadzone — so a single filter carries one state per helm. Shared across
2745
- // instances (step is synchronous, at most once per helm per frame).
2746
- const _f6raw = [0, 0, 0, 0, 0, 0];
2747
- const _f6out = [0, 0, 0, 0, 0, 0];
2748
- const _fLin = [0, 0, 0];
2749
- const _fAng = [0, 0, 0];
2750
-
2751
- /**
2752
- * Default full-deflection input magnitude — the fed rate a read-out shows as
2753
- * full (the SpaceNavigator's saturated lane). Private to core: a transport on a
2754
- * different raw scale sets helm.fullScale and never reads this, so there is no
2755
- * public HELM_FULL export. Display-only; integration uses profile.sens directly.
2756
- */
2757
- const HELM_FULL_SCALE = 500;
2758
-
2759
- // Deadzone gate — |v| > dz keeps v, else 0. Module-level so step allocates no
2760
- // closure. Strictly-greater matches the e7 reference (rest reads exact 0).
2761
- const _dz = (v, dz) => (v > dz || v < -dz) ? v : 0;
2762
-
2763
- /**
2764
- * Build the canonical default profile. SpaceNavigator-tuned lane permutation
2765
- * and the eye-frame manipulation feel found in the e7 experiments; it is the
2766
- * per-app / per-device mapping layer and is meant to be overwritten wholesale
2767
- * (`helm.profile = { … }`) for a different transport.
2768
- *
2769
- * sign — per-app direction (camera-fly vs object-grab invert; flip live).
2770
- * sens — per-axis sensitivity (tame roll without touching the rest).
2771
- * lane — input-channel permutation: which fed channel drives this DOF.
2772
- * T* lanes index the translation triple; R* lanes the rotation triple.
2773
- */
2774
- function _defaultProfile() {
2775
- return {
2776
- Tx: { sign: 1, sens: 0.30, lane: 0 },
2777
- Ty: { sign: 1, sens: 0.30, lane: 2 },
2778
- Tz: { sign: -1, sens: 0.30, lane: 1 },
2779
- Rp: { sign: -1, sens: 0.0025, lane: 0 },
2780
- Ry: { sign: -1, sens: 0.0025, lane: 2 },
2781
- Rr: { sign: 1, sens: 0.0018, lane: 1 },
2782
- };
2783
- }
2784
-
2785
- // =========================================================================
2786
- // PoseHelm
2787
- // =========================================================================
2788
-
2789
- /**
2790
- * Source-agnostic 6-DOF pose producer. Holds a profile + the integrated pose;
2791
- * no timeline (no keyframes, no play/seek/loop).
2792
- */
2793
- class PoseHelm {
2794
- constructor() {
2795
- /**
2796
- * DOF mapping table. Six channels, each `{ sign, sens, lane }`.
2797
- * Public and mutable — set wholesale to re-map a device or app.
2798
- * @type {{ Tx:Object, Ty:Object, Tz:Object, Rp:Object, Ry:Object, Rr:Object }}
2799
- */
2800
- this.profile = _defaultProfile();
2801
-
2802
- /**
2803
- * Rest-drift floor. A fed rate with |value| ≤ deadzone reads as 0. Devices
2804
- * that rest at exact 0 need only a small floor. @type {number}
2805
- */
2806
- this.deadzone = 8;
2807
-
2808
- /**
2809
- * Full-deflection input magnitude for the read-outs (gizmo overlay + panel
2810
- * meters): a fed rate of |fullScale| reads as a full bar / arrow. Set by a
2811
- * transport whose saturated rate differs from the default (a gamepad stick
2812
- * that saturates at 1 sets fullScale = 1). Display-only — integration uses
2813
- * profile.sens directly, so this never affects flight. @type {number}
2814
- */
2815
- this.fullScale = HELM_FULL_SCALE;
2816
-
2817
- /**
2818
- * Optional input conditioner applied to the fed rates BEFORE the deadzone,
2819
- * inside step — a oneEuro filter, or any f(out, raw, dt) carrying function
2820
- * with a reset(). null (the default) is the clean rate-native path: the
2821
- * filter branch is skipped, so a clean device pays nothing. Set for noisy /
2822
- * absolute sources; home() resets it. @type {?Function}
2823
- */
2824
- this.filter = null;
2825
-
2826
- /**
2827
- * The space fed rates are interpreted in: a space constant (WORLD | EYE) or
2828
- * a mat4 frame. Declarative only — the bridge reads this to resolve the
2829
- * per-step `basis`; the core stays camera-agnostic. @type {string|ArrayLike<number>}
2830
- */
2831
- this.from = 'EYE';
2832
-
2833
- // Latest fed raw rates (refreshed by feed; persist until the next feed —
2834
- // the transport is responsible for feeding 0 when motion should stop).
2835
- this._lin = [0, 0, 0];
2836
- this._ang = [0, 0, 0];
2837
-
2838
- // Integrated pose — the source of truth.
2839
- this._pos = [0, 0, 0];
2840
- this._q = [0, 0, 0, 1];
2841
- }
2842
-
2843
- /**
2844
- * Push the latest raw 6-DOF device rate. Either argument may be omitted to
2845
- * leave that half unchanged (devices report the two halves on separate
2846
- * frames). The value persists until the next feed. Raw device units — the
2847
- * profile's `sens` does the scaling, so the same feed() suits any transport.
2848
- *
2849
- * @param {ArrayLike<number>} [translation] [tx, ty, tz] raw lane rates.
2850
- * @param {ArrayLike<number>} [rotation] [rx, ry, rz] raw lane rates.
2851
- * @returns {PoseHelm} this
2852
- */
2853
- feed(translation, rotation) {
2854
- if (translation) {
2855
- this._lin[0] = translation[0] || 0;
2856
- this._lin[1] = translation[1] || 0;
2857
- this._lin[2] = translation[2] || 0;
2858
- }
2859
- if (rotation) {
2860
- this._ang[0] = rotation[0] || 0;
2861
- this._ang[1] = rotation[1] || 0;
2862
- this._ang[2] = rotation[2] || 0;
2863
- }
2864
- return this;
2865
- }
2866
-
2867
- /**
2868
- * Integrate the latest fed rate by `dt` seconds into the pose, then write the
2869
- * pose into `out`. Bridge-driven (the player calls it each frame; a sketch
2870
- * never does, as it never calls track.tick()).
2871
- *
2872
- * `basis` is the resolved `from` frame: an eye→world column-major mat4
2873
- * (16-element ArrayLike), or null/omitted for WORLD (the identity basis). Its
2874
- * columns supply the right/up/back axes; forward is −col2. Both the linear and
2875
- * angular rates are rotated through it, then `q` is composed world-frame.
2876
- *
2877
- * Zero-allocation. `out` is `{ pos:number[3], rot:number[4] }`; omit it for a
2878
- * fresh object.
2879
- *
2880
- * @param {{ pos:number[], rot:number[] }} [out]
2881
- * @param {number} dt Elapsed time in seconds.
2882
- * @param {ArrayLike<number>} [basis] Eye→world mat4, or null for WORLD.
2883
- * @returns {{ pos:number[], rot:number[] }} out
2884
- */
2885
- step(out, dt, basis) {
2886
- const p = this.profile, dz = this.deadzone;
2887
- let lin = this._lin, ang = this._ang;
2888
-
2889
- // Value layer — condition the fed rates BEFORE the deadzone (the filter
2890
- // narrows the jitter band; the deadzone then gates the residual to exact
2891
- // zero). The filter carries state, so it is ticked exactly once per step;
2892
- // activity() reads the raw fed rate and is left unfiltered (the read-out
2893
- // shows device input, normalized by fullScale). Skipped when filter is null.
2894
- const filter = this.filter;
2895
- if (filter) {
2896
- _f6raw[0] = lin[0]; _f6raw[1] = lin[1]; _f6raw[2] = lin[2];
2897
- _f6raw[3] = ang[0]; _f6raw[4] = ang[1]; _f6raw[5] = ang[2];
2898
- filter(_f6out, _f6raw, dt);
2899
- _fLin[0] = _f6out[0]; _fLin[1] = _f6out[1]; _fLin[2] = _f6out[2];
2900
- _fAng[0] = _f6out[3]; _fAng[1] = _f6out[4]; _fAng[2] = _f6out[5];
2901
- lin = _fLin; ang = _fAng;
2902
- }
2903
-
2904
- // Basis axes (right / up / forward). No basis ⇒ the identity eye matrix:
2905
- // right +X, up +Y, forward −Z. Forward is −col2 (an eye→world matrix stores
2906
- // the camera's BACK in col2), and the identity's col2 is +Z, so forward is
2907
- // −Z — making step(out, dt, null) identical to step(out, dt, IDENTITY_MAT4),
2908
- // as the `from` contract ("null ≡ the identity basis") promises.
2909
- let rX = 1, rY = 0, rZ = 0;
2910
- let uX = 0, uY = 1, uZ = 0;
2911
- let fX = 0, fY = 0, fZ = -1;
2912
- if (basis) {
2913
- rX = basis[0]; rY = basis[1]; rZ = basis[2];
2914
- uX = basis[4]; uY = basis[5]; uZ = basis[6];
2915
- fX = -basis[8]; fY = -basis[9]; fZ = -basis[10];
2916
- }
2917
-
2918
- // Angular: three rates → one delta quat, rotated into world, world-composed.
2919
- const wx = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
2920
- const wy = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
2921
- const wz = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
2922
- const w = Math.sqrt(wx * wx + wy * wy + wz * wz);
2923
- if (w > 0) {
2924
- // Axis in world = basis · (wx,wy,wz); its length is w (basis orthonormal),
2925
- // so the rotation angle is w·dt. qFromAxisAngle renormalizes the axis.
2926
- const ax = wx * rX + wy * uX + wz * fX;
2927
- const ay = wx * rY + wy * uY + wz * fY;
2928
- const az = wx * rZ + wy * uZ + wz * fZ;
2929
- qFromAxisAngle(_dq, ax, ay, az, w * dt);
2930
- qMul(this._q, _dq, this._q); // delta already in world → world-frame compose
2931
- qNormalize(this._q);
2932
- }
2933
-
2934
- // Linear: three rates rotated into world, integrated by dt.
2935
- const tx = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
2936
- const ty = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
2937
- const tz = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
2938
- this._pos[0] += (tx * rX + ty * uX + tz * fX) * dt;
2939
- this._pos[1] += (tx * rY + ty * uY + tz * fY) * dt;
2940
- this._pos[2] += (tx * rZ + ty * uZ + tz * fZ) * dt;
2941
-
2942
- return this.eval(out);
2943
- }
2944
-
2945
- /**
2946
- * Read the current pose into `out` (zero-alloc). The shared output contract
2947
- * that feeds applyPose — mirrors Track.eval.
2948
- *
2949
- * @param {{ pos:number[], rot:number[] }} [out]
2950
- * @returns {{ pos:number[], rot:number[] }} out
2951
- */
2952
- eval(out) {
2953
- out = out || { pos: [0, 0, 0], rot: [0, 0, 0, 1] };
2954
- out.pos[0] = this._pos[0]; out.pos[1] = this._pos[1]; out.pos[2] = this._pos[2];
2955
- out.rot[0] = this._q[0]; out.rot[1] = this._q[1];
2956
- out.rot[2] = this._q[2]; out.rot[3] = this._q[3];
2957
- return out;
2958
- }
2959
-
2960
- /**
2961
- * Write the six effective channel rates (post deadzone·sign·sens) into `out6`,
2962
- * in channel order [Tx, Ty, Tz, Rp, Ry, Rr]. The gizmo reads this to light the
2963
- * DOFs being driven; readouts can show signed magnitudes. Zero-alloc.
2964
- *
2965
- * @param {number[]} out6 6-element destination.
2966
- * @returns {number[]} out6
2967
- */
2968
- activity(out6) {
2969
- const p = this.profile, dz = this.deadzone, lin = this._lin, ang = this._ang;
2970
- out6[0] = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
2971
- out6[1] = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
2972
- out6[2] = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
2973
- out6[3] = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
2974
- out6[4] = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
2975
- out6[5] = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
2976
- return out6;
2977
- }
2978
-
2979
- /**
2980
- * Re-home the integrated pose. Sets position + orientation to `pose` (or the
2981
- * identity pose when omitted) and clears any pending rate so the helm rests at
2982
- * a clean, known state. NOT a reset — there are no keyframes to clear.
2983
- *
2984
- * @param {{ pos?:ArrayLike<number>, rot?:ArrayLike<number> }} [pose]
2985
- * @returns {PoseHelm} this
2986
- */
2987
- home(pose) {
2988
- if (pose && pose.pos) {
2989
- this._pos[0] = pose.pos[0]; this._pos[1] = pose.pos[1]; this._pos[2] = pose.pos[2];
2990
- } else {
2991
- this._pos[0] = 0; this._pos[1] = 0; this._pos[2] = 0;
2992
- }
2993
- if (pose && pose.rot) {
2994
- this._q[0] = pose.rot[0]; this._q[1] = pose.rot[1];
2995
- this._q[2] = pose.rot[2]; this._q[3] = pose.rot[3];
2996
- } else {
2997
- this._q[0] = 0; this._q[1] = 0; this._q[2] = 0; this._q[3] = 1;
2998
- }
2999
- this._lin[0] = this._lin[1] = this._lin[2] = 0;
3000
- this._ang[0] = this._ang[1] = this._ang[2] = 0;
3001
- if (this.filter) this.filter.reset(); // drop filter state at the discontinuity
3002
- return this;
3003
- }
3004
- }
3005
-
3006
- /**
3007
- * @file Camera state ↔ matrices: builders, decomposers, and in-place edits.
3008
- * @module tree/camera
3009
- * @license AGPL-3.0-only
3010
- *
3011
- * The camera is plain data — the CameraTrack keyframe shape:
3012
- *
3013
- * cam = {
3014
- * eye: [x, y, z], // position
3015
- * center: [x, y, z], // lookat target; |center − eye| is the gaze distance
3016
- * up: [x, y, z], // up hint, need not be unit
3017
- * fov: number | null, // vertical field of view, radians — perspective
3018
- * halfHeight: number | null, // world-unit half-height at the near plane — orthographic
3019
- * near: number, // > 0
3020
- * far: number, // > near
3021
- * }
3022
- *
3023
- * Exactly one of fov / halfHeight is meaningful; fov wins when both are set.
3024
- * With both null the lens is "unchanged": cameraProj and cameraPlanes return
3025
- * null and leave their output untouched, so a track that evaluates null into
3026
- * the state keeps the last projection installed. A track writes the state
3027
- * directly — `track.eval(cam)` — and a pose drives it through cameraFromPose.
3028
- *
3029
- * The state carries no aspect ratio — the viewport owns it — so one state is
3030
- * portable between targets of different sizes; every builder that needs it
3031
- * takes it as an argument.
3032
- *
3033
- * Vectors are plain number[] (f64, authoring state); matrices are written into
3034
- * whatever 16-element buffer the caller passes. createCamera is the one
3035
- * allocating call; everything else is out-first (or in-place) and zero-alloc.
3036
- *
3037
- * Every function that needs the camera frame derives it through mat4Eye, so
3038
- * planes and edits agree with cameraEye / cameraView exactly — including the
3039
- * up re-seed when the view direction is parallel to the up hint.
3040
- */
3041
-
3042
-
3043
- const _E$1 = new Float64Array(16); // eye→world frame scratch: right 0–2, up 4–6, back 8–10
3044
- const _d = [0, 0, 0]; // view-direction scratch
3045
-
3046
- /** Eye→world frame of `cam` into `_E`. */
3047
- function _frame(cam) {
3048
- const e = cam.eye, c = cam.center, u = cam.up;
3049
- mat4Eye(_E$1, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
3050
- }
3051
-
3052
- /** Gaze distance |center − eye|, or 1 when the state is degenerate. */
3053
- function _gaze(cam) {
3054
- const dx = cam.center[0]-cam.eye[0], dy = cam.center[1]-cam.eye[1], dz = cam.center[2]-cam.eye[2];
3055
- const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
3056
- return d > 0 ? d : 1;
3057
- }
3058
-
3059
- function _vec3(v, x, y, z) { return v != null ? [v[0], v[1], v[2]] : [x, y, z]; }
3060
-
3061
- // =========================================================================
3062
- // State
3063
- // =========================================================================
3064
-
3065
- /**
3066
- * Allocate a camera state — the one allocating call, setup-time.
3067
- * Defaults: eye [0, 0, 500], center [0, 0, 0], up [0, 1, 0], fov π/3,
3068
- * halfHeight null, near 0.1, far 1000. Passing halfHeight without fov yields
3069
- * an orthographic state (fov null).
3070
- *
3071
- * @param {{ eye?:number[], center?:number[], up?:number[], fov?:number|null,
3072
- * halfHeight?:number|null, near?:number, far?:number }} [opts]
3073
- * @returns {{ eye:number[], center:number[], up:number[], fov:number|null,
3074
- * halfHeight:number|null, near:number, far:number }}
3075
- */
3076
- function createCamera(opts) {
3077
- const o = {};
3078
- const ortho = o.halfHeight != null && o.fov === undefined;
3079
- return {
3080
- eye: _vec3(o.eye, 0, 0, 500),
3081
- center: _vec3(o.center, 0, 0, 0),
3082
- up: _vec3(o.up, 0, 1, 0),
3083
- fov: o.fov !== undefined ? o.fov : (ortho ? null : Math.PI / 3),
3084
- halfHeight: o.halfHeight !== undefined ? o.halfHeight : null,
3085
- near: typeof o.near === 'number' ? o.near : 0.1,
3086
- far: typeof o.far === 'number' ? o.far : 1000,
3087
- };
3088
- }
3089
-
3090
- /**
3091
- * Copy one camera state into another (an orbit's home, a track's capture).
3092
- * @param {object} out Destination state.
3093
- * @param {object} cam Source state.
3094
- * @returns {object} out
3095
- */
3096
- function cameraCopy(out, cam) {
3097
- out.eye[0]=cam.eye[0]; out.eye[1]=cam.eye[1]; out.eye[2]=cam.eye[2];
3098
- out.center[0]=cam.center[0]; out.center[1]=cam.center[1]; out.center[2]=cam.center[2];
3099
- out.up[0]=cam.up[0]; out.up[1]=cam.up[1]; out.up[2]=cam.up[2];
3100
- out.fov = cam.fov; out.halfHeight = cam.halfHeight;
3101
- out.near = cam.near; out.far = cam.far;
3102
- return out;
3103
- }
3104
-
3105
- // =========================================================================
3106
- // Builders — state → matrices
3107
- // =========================================================================
3108
-
3109
- /**
3110
- * View matrix (world→eye) from the state's lookat.
3111
- * @param {Float32Array|number[]} out 16-element destination.
3112
- * @param {object} cam
3113
- * @returns {Float32Array|number[]} out
3114
- */
3115
- function cameraView(out, cam) {
3116
- const e = cam.eye, c = cam.center, u = cam.up;
3117
- return mat4View(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
3118
- }
3119
-
3120
- /**
3121
- * Eye matrix (eye→world) from the state's lookat.
3122
- * @param {Float32Array|number[]} out 16-element destination.
3123
- * @param {object} cam
3124
- * @returns {Float32Array|number[]} out
3125
- */
3126
- function cameraEye(out, cam) {
3127
- const e = cam.eye, c = cam.center, u = cam.up;
3128
- return mat4Eye(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
3129
- }
3130
-
3131
- /**
3132
- * Projection matrix from the state's lens: mat4Persp from fov, or mat4Ortho
3133
- * from halfHeight, with symmetric extents (top = near · tan(fov / 2) or
3134
- * halfHeight; right = top · aspect).
3135
- *
3136
- * @param {Float32Array|number[]} out 16-element destination.
3137
- * @param {object} cam
3138
- * @param {number} aspect Viewport width / height.
3139
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
3140
- * @param {number} [ndcYSign=1] +1 NDC y-up; −1 NDC y-down.
3141
- * @returns {Float32Array|number[]|null} out, or null (out untouched) when
3142
- * both fov and halfHeight are null.
3143
- */
3144
- function cameraProj(out, cam, aspect, ndcZMin, ndcYSign = 1) {
3145
- const near = cam.near, far = cam.far;
3146
- if (cam.fov != null) {
3147
- const top = near * Math.tan(cam.fov / 2), right = top * aspect;
3148
- return mat4Persp(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
3149
- }
3150
- if (cam.halfHeight != null) {
3151
- const top = cam.halfHeight, right = top * aspect;
3152
- return mat4Ortho(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
3153
- }
3154
- return null;
3155
- }
3156
-
3157
- /**
3158
- * Drive the lookat from a TRS pose: eye ← pos, up and forward from the
3159
- * rotation's columns 1 and −2, center ← eye + forward · d with d the current
3160
- * gaze distance (1 when degenerate). The lens is untouched; `scl` is ignored.
3161
- *
3162
- * @param {object} cam State written in place.
3163
- * @param {{ pos:number[], rot:number[] }} pose rot is a unit quaternion [x,y,z,w].
3164
- * @returns {object} cam
3165
- */
3166
- function cameraFromPose(cam, pose) {
3167
- const d = _gaze(cam);
3168
- const q = pose.rot, x=q[0], y=q[1], z=q[2], w=q[3];
3169
- const x2=x+x, y2=y+y, z2=z+z;
3170
- const xx=x*x2, xy=x*y2, xz=x*z2, yy=y*y2, yz=y*z2, zz=z*z2, wx=w*x2, wy=w*y2, wz=w*z2;
3171
- const ux=xy-wz, uy=1-(xx+zz), uz=yz+wx; // column 1 of qToMat4(rot): up
3172
- const bx=xz+wy, by=yz-wx, bz=1-(xx+yy); // column 2: back
3173
- cam.eye[0]=pose.pos[0]; cam.eye[1]=pose.pos[1]; cam.eye[2]=pose.pos[2];
3174
- cam.up[0]=ux; cam.up[1]=uy; cam.up[2]=uz;
3175
- cam.center[0]=cam.eye[0]-bx*d; cam.center[1]=cam.eye[1]-by*d; cam.center[2]=cam.eye[2]-bz*d;
3176
- return cam;
3177
- }
3178
-
3179
- /**
3180
- * The lookat as a TRS pose: pos ← eye, rot ← qFromLookDir(center − eye, up) —
3181
- * the rotation of cameraEye, so a helm seeded from it continues the frame.
3182
- *
3183
- * @param {{ pos:number[], rot:number[] }} pose Written in place.
3184
- * @param {object} cam
3185
- * @returns {{ pos:number[], rot:number[] }} pose
3186
- */
3187
- function cameraToPose(pose, cam) {
3188
- pose.pos[0]=cam.eye[0]; pose.pos[1]=cam.eye[1]; pose.pos[2]=cam.eye[2];
3189
- _d[0]=cam.center[0]-cam.eye[0]; _d[1]=cam.center[1]-cam.eye[1]; _d[2]=cam.center[2]-cam.eye[2];
3190
- qFromLookDir(pose.rot, _d, cam.up);
3191
- return pose;
3192
- }
3193
-
3194
- // =========================================================================
3195
- // Edits — in place, chainable, zero-alloc
3196
- // =========================================================================
3197
-
3198
- /**
3199
- * Orbit the eye about the center: `dAz` rotates it about the up hint
3200
- * (right-handed); `dEl` raises its elevation above the plane through the
3201
- * center perpendicular to the hint, clamped to ±opts.maxEl so the view
3202
- * direction never reaches the hint (the pole guard). `up` is left as the hint
3203
- * it was, so an orbit never rolls. A state already at the pole is pulled
3204
- * inside the guard by its first non-zero edit; (0, 0) is a no-op.
3205
- *
3206
- * @param {object} cam
3207
- * @param {number} dAz Azimuth delta, radians.
3208
- * @param {number} dEl Elevation delta, radians.
3209
- * @param {{ maxEl?:number }} [opts] Elevation limit; default π/2 − 1e-3.
3210
- * @returns {object} cam
3211
- */
3212
- function cameraOrbit(cam, dAz, dEl, opts) {
3213
- if (dAz === 0 && dEl === 0) return cam;
3214
- const maxEl = Math.PI / 2 - 1e-3;
3215
- const e = cam.eye, c = cam.center;
3216
- let ux=cam.up[0], uy=cam.up[1], uz=cam.up[2];
3217
- const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
3218
- ux/=ul; uy/=ul; uz/=ul;
3219
- const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
3220
- const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
3221
- _frame(cam);
3222
- const px=_E$1[4], py=_E$1[5], pz=_E$1[6]; // the eye's up
3223
- const bx=_E$1[8], by=_E$1[9], bz=_E$1[10]; // back: center → eye, unit
3224
- // elevation: raise by θ within the (back, up) plane, clamped
3225
- const el = Math.asin(Math.max(-1, Math.min(1, bx*ux+by*uy+bz*uz)));
3226
- const th = Math.max(-maxEl, Math.min(maxEl, el + dEl)) - el;
3227
- const ct = Math.cos(th), st = Math.sin(th);
3228
- const ox = r*(bx*ct+px*st), oy = r*(by*ct+py*st), oz = r*(bz*ct+pz*st);
3229
- // azimuth: rotate about the hint (Rodrigues)
3230
- const ca = Math.cos(dAz), sa = Math.sin(dAz), k = (ux*ox+uy*oy+uz*oz)*(1-ca);
3231
- e[0] = c[0] + ox*ca + (uy*oz-uz*oy)*sa + ux*k;
3232
- e[1] = c[1] + oy*ca + (uz*ox-ux*oz)*sa + uy*k;
3233
- e[2] = c[2] + oz*ca + (ux*oy-uy*ox)*sa + uz*k;
3234
- return cam;
3235
- }
3236
-
3237
- /**
3238
- * Dolly: scale the gaze distance by `factor` — the eye moves along the view
3239
- * direction under perspective; under orthographic (fov null, halfHeight set)
3240
- * halfHeight is scaled instead, since moving the eye changes nothing on
3241
- * screen. opts.min / opts.max clamp the scaled quantity. An edit that would
3242
- * make it non-positive is a no-op.
3243
- *
3244
- * @param {object} cam
3245
- * @param {number} factor
3246
- * @param {{ min?:number, max?:number }} [opts]
3247
- * @returns {object} cam
3248
- */
3249
- function cameraDolly(cam, factor, opts) {
3250
- const min = opts && typeof opts.min === 'number' ? opts.min : 0;
3251
- const max = opts && typeof opts.max === 'number' ? opts.max : Infinity;
3252
- if (cam.fov == null && cam.halfHeight != null) {
3253
- const h = Math.max(min, Math.min(max, cam.halfHeight * factor));
3254
- if (h > 0) cam.halfHeight = h;
3255
- return cam;
3256
- }
3257
- const e = cam.eye, c = cam.center;
3258
- const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
3259
- const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
3260
- if (r === 0) return cam;
3261
- const r1 = Math.max(min, Math.min(max, r * factor));
3262
- if (!(r1 > 0)) return cam;
3263
- const s = r1 / r;
3264
- e[0]=c[0]+dx*s; e[1]=c[1]+dy*s; e[2]=c[2]+dz*s;
3265
- return cam;
3266
- }
3267
-
3268
- /**
3269
- * Pan: translate eye and center by `dx` along the eye's right and `dy` along
3270
- * its up, world units. The caller converts pixels through pixelRatio at the
3271
- * center's depth so a pan tracks the pointer.
3272
- *
3273
- * @param {object} cam
3274
- * @param {number} dx
3275
- * @param {number} dy
3276
- * @returns {object} cam
3277
- */
3278
- function cameraPan(cam, dx, dy) {
3279
- _frame(cam);
3280
- const tx=_E$1[0]*dx+_E$1[4]*dy, ty=_E$1[1]*dx+_E$1[5]*dy, tz=_E$1[2]*dx+_E$1[6]*dy;
3281
- const e = cam.eye, c = cam.center;
3282
- e[0]+=tx; e[1]+=ty; e[2]+=tz;
3283
- c[0]+=tx; c[1]+=ty; c[2]+=tz;
3284
- return cam;
3285
- }
1
+ import { WEBGL, cameraView, cameraProj, mat4Mul, mat4Invert, PLANE, createConstraint, WORLD, AXIS, DIAL, POINT, mapDirection, pixelRatio, rayHitSphere, unproject, SPHERE, dirFromAzEl, DIRECTION, mapLocation, MATRIX, PoseHelm, cameraToPose, EYE, SELF, qToMat4, cameraEye, cameraFromPose, CameraTrack, PoseTrack, qFromAxisAngle, SCREEN, cameraCopy, createCamera, coastAlive, coastStep, cameraOrbit, cameraPan, cameraDolly } from '@nakednous/tree';
3286
2
 
3287
3
  /**
3288
4
  * @file The view bag — the matrices every pick, solve, label and orbit reads.
@@ -3685,7 +401,7 @@ function createPlayers() {
3685
401
  return players;
3686
402
  }
3687
403
 
3688
- const _now$1 = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
404
+ const _now$2 = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
3689
405
 
3690
406
  /**
3691
407
  * Create a frame loop. `onFrame(dt)` runs once per animation frame with the
@@ -3701,7 +417,7 @@ function createLoop(opts) {
3701
417
  const o = opts || {};
3702
418
  const raf = o.raf || ((cb) => requestAnimationFrame(cb));
3703
419
  const caf = o.caf || ((id) => cancelAnimationFrame(id));
3704
- const now = o.now || _now$1;
420
+ const now = o.now || _now$2;
3705
421
  const clamp = (o.clampMs ?? 50) / 1000;
3706
422
  let handle = null, last = 0;
3707
423
  const loop = {
@@ -5132,10 +1848,88 @@ function createGamepad(host, opts) {
5132
1848
  }
5133
1849
 
5134
1850
  /**
5135
- * @file Media sources — images, video and Canvas2D rasters for a bridge's textures.
1851
+ * @file OBJ text → the arrays shape a bridge's buffer upload takes.
1852
+ * @module host/obj
1853
+ * @license AGPL-3.0-only
1854
+ *
1855
+ * Positions, texture coordinates and normals. Faces are fan-triangulated — a
1856
+ * quad as 0 1 2 · 2 3 0 — and each distinct position / uv / normal triple
1857
+ * becomes one vertex, in order of first use. Indices are 1-based, or negative
1858
+ * counting back from the last declared element. Groups, objects, smoothing and
1859
+ * materials are ignored.
1860
+ */
1861
+
1862
+
1863
+ const _index = (s, count) => {
1864
+ const i = parseInt(s, 10);
1865
+ return i < 0 ? count + i : i - 1;
1866
+ };
1867
+
1868
+ /**
1869
+ * Parse OBJ text into `{ position, normal?, texcoord?, indices }`; normal and
1870
+ * texcoord only when some face vertex names one.
1871
+ * @param {string} text
1872
+ * @returns {{ position:{numComponents:number,data:Float32Array},
1873
+ * normal?:{numComponents:number,data:Float32Array},
1874
+ * texcoord?:{numComponents:number,data:Float32Array},
1875
+ * indices:{numComponents:number,data:Uint32Array} }}
1876
+ */
1877
+ function parseObj(text) {
1878
+ const v = [], vt = [], vn = [];
1879
+ const position = [], texcoord = [], normal = [], indices = [];
1880
+ const seen = new Map();
1881
+ let uvs = false, normals = false;
1882
+
1883
+ const vertex = (token) => {
1884
+ const [ps, ts, ns] = token.split('/');
1885
+ const p = _index(ps, v.length / 3);
1886
+ const t = ts ? _index(ts, vt.length / 2) : -1;
1887
+ const n = ns ? _index(ns, vn.length / 3) : -1;
1888
+ const key = p + '/' + t + '/' + n;
1889
+ let id = seen.get(key);
1890
+ if (id !== undefined) return id;
1891
+ position.push(v[3 * p], v[3 * p + 1], v[3 * p + 2]);
1892
+ if (t >= 0) { texcoord.push(vt[2 * t], vt[2 * t + 1]); uvs = true; } else texcoord.push(0, 0);
1893
+ if (n >= 0) { normal.push(vn[3 * n], vn[3 * n + 1], vn[3 * n + 2]); normals = true; } else normal.push(0, 0, 0);
1894
+ id = position.length / 3 - 1;
1895
+ seen.set(key, id);
1896
+ return id;
1897
+ };
1898
+
1899
+ for (const raw of text.split('\n')) {
1900
+ const line = raw.trim();
1901
+ if (!line || line[0] === '#') continue;
1902
+ const parts = line.split(/\s+/);
1903
+ switch (parts[0]) {
1904
+ case 'v': v.push(+parts[1], +parts[2], +parts[3]); break;
1905
+ case 'vt': vt.push(+parts[1], +(parts[2] ?? 0)); break;
1906
+ case 'vn': vn.push(+parts[1], +parts[2], +parts[3]); break;
1907
+ case 'f': {
1908
+ const ids = parts.slice(1).map(vertex);
1909
+ if (ids.length === 4) indices.push(ids[0], ids[1], ids[2], ids[2], ids[3], ids[0]);
1910
+ else for (let i = 1; i + 1 < ids.length; i++) indices.push(ids[0], ids[i], ids[i + 1]);
1911
+ break;
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ const out = {
1917
+ position: { numComponents: 3, data: new Float32Array(position) },
1918
+ indices: { numComponents: 3, data: new Uint32Array(indices) },
1919
+ };
1920
+ if (normals) out.normal = { numComponents: 3, data: new Float32Array(normal) };
1921
+ if (uvs) out.texcoord = { numComponents: 2, data: new Float32Array(texcoord) };
1922
+ return out;
1923
+ }
1924
+
1925
+ /**
1926
+ * @file Media sources — images, video and Canvas2D rasters for a bridge's textures, OBJ models for its buffers.
5136
1927
  * @module host/media
5137
1928
  * @license AGPL-3.0-only
5138
1929
  *
1930
+ * A model arrives as arrays for a bridge's buffer upload: loadModel fetches an
1931
+ * OBJ file and parses it (host/obj).
1932
+ *
5139
1933
  * What a texture upload reads from: an ImageBitmap fetched from a URL, a
5140
1934
  * hidden <video> element from a file or the user's camera, a bitmap drawn
5141
1935
  * through a 2D context (the text path — glyph strips, billboard tags,
@@ -5161,6 +1955,28 @@ async function loadImage(url, opts) {
5161
1955
  return o.bitmap ? createImageBitmap(blob, o.bitmap) : createImageBitmap(blob);
5162
1956
  }
5163
1957
 
1958
+ /**
1959
+ * Fetch an OBJ model into the arrays shape: position and indices, plus normal
1960
+ * and texcoord when the file carries them. twgl's createBufferInfoFromArrays
1961
+ * takes the result as it is.
1962
+ *
1963
+ * Faces are triangulated and each distinct position / uv / normal triple
1964
+ * becomes one vertex. Materials are ignored.
1965
+ *
1966
+ * @param {string} url
1967
+ * @param {{ fetch?:object }} [opts] fetch: the fetch init (credentials, mode, …).
1968
+ * @returns {Promise<{ position:{numComponents:number,data:Float32Array},
1969
+ * normal?:{numComponents:number,data:Float32Array},
1970
+ * texcoord?:{numComponents:number,data:Float32Array},
1971
+ * indices:{numComponents:number,data:Uint32Array} }>}
1972
+ */
1973
+ async function loadModel(url, opts) {
1974
+ const o = opts || {};
1975
+ const res = await fetch(url, o.fetch);
1976
+ if (!res.ok) throw new Error('[host] model: ' + url + ' → ' + res.status);
1977
+ return parseObj(await res.text());
1978
+ }
1979
+
5164
1980
  /**
5165
1981
  * A hidden, muted, inline <video> element from a file or the user's camera.
5166
1982
  * `ready` resolves once metadata arrived (width / height known); a camera
@@ -5469,9 +2285,14 @@ function createLabels(host, opts) {
5469
2285
  * Two-pointer motion is computed from the pointers' own coordinates, never
5470
2286
  * from a browser's pinch synthesis, so it reads the same on every platform;
5471
2287
  * the canvas gets touch-action: none while the orbit lives so the browser
5472
- * does not scroll or zoom the page instead. No damping, no inertia: direct
5473
- * manipulation wants exactness, and a sketch that wants smoothing lerps the
5474
- * camera it owns.
2288
+ * does not scroll or zoom the page instead. `damping`, seconds, makes the
2289
+ * camera trail the gesture through a first-order lag: each step of a drag —
2290
+ * orbit, pan and dolly lanes — and each wheel notch is an impulse the camera
2291
+ * drains with that time constant through tree's coast, so it follows the
2292
+ * finger about `damping` behind and, after a release, travels the rest of
2293
+ * what the finger did and no more, a flick easing out. A fresh touch drops
2294
+ * what is still draining, so do home() and enabled = false. The default is
2295
+ * 0.2 s; 0 makes the orbit exact.
5475
2296
  *
5476
2297
  * Screen y runs down; which world direction that is depends on the
5477
2298
  * projection installed in the view bag: a y-up projection (GL's, tree's
@@ -5482,19 +2303,23 @@ function createLabels(host, opts) {
5482
2303
 
5483
2304
 
5484
2305
  const _clamp = { min: 0, max: Infinity };
2306
+ const _now$1 = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
2307
+ const EPS = 1e-4; // a rate below this in every lane ends the drain
2308
+ const DT_MAX = 0.1; // seconds, the measured frame's clamp
5485
2309
 
5486
2310
  /**
5487
2311
  * Create an orbit on a camera state.
5488
2312
  *
5489
2313
  * @param {object} host
5490
2314
  * @param {object} cam The camera state written.
5491
- * @param {{ rotate?:number, pan?:number, zoom?:number, wheel?:number,
2315
+ * @param {{ rotate?:number, pan?:number, zoom?:number, wheel?:number, damping?:number,
5492
2316
  * minDistance?:number, maxDistance?:number, enabled?:boolean }} [opts]
5493
2317
  * rotate: radians per pixel (default 0.005). pan / zoom / wheel:
5494
2318
  * scalars on the two-pointer pan, the pinch and the wheel dolly
5495
- * (default 1). minDistance / maxDistance: gaze-distance clamps.
5496
- * @returns {object} The orbit: { cam, enabled, rotate, pan, zoom, wheel,
5497
- * minDistance, maxDistance, update(), home(), dispose() }.
2319
+ * (default 1). damping: the lag's time constant in seconds (default
2320
+ * 0.2; 0 exact). minDistance / maxDistance: gaze-distance clamps.
2321
+ * @returns {object} The orbit: { cam, enabled, rotate, pan, zoom, wheel, damping,
2322
+ * minDistance, maxDistance, update(dt), home(), dispose() }.
5498
2323
  */
5499
2324
  function createOrbit(host, cam, opts) {
5500
2325
  const o = opts || {};
@@ -5502,6 +2327,16 @@ function createOrbit(host, cam, opts) {
5502
2327
  const home = cameraCopy(createCamera(), cam);
5503
2328
  const tracked = new Map(); // pointer id → { x, y } as last consumed
5504
2329
  let wheelAcc = 0;
2330
+ const rate = [0, 0, 0, 0, 0]; // what is still draining: dAz, dEl, panX, panY, log dolly — per second
2331
+ const step = [0, 0, 0, 0, 0]; // one frame of it
2332
+ let lastNow = 0, wasPressed = false;
2333
+
2334
+ const cancel = () => { for (let i = 0; i < 5; i++) rate[i] = 0; };
2335
+ const apply = (s) => {
2336
+ if (s[0] !== 0 || s[1] !== 0) cameraOrbit(cam, s[0], s[1]);
2337
+ if (s[2] !== 0 || s[3] !== 0) cameraPan(cam, s[2], s[3]);
2338
+ if (s[4] !== 0) dolly(Math.exp(s[4]));
2339
+ };
5505
2340
 
5506
2341
  const onWheel = (e) => {
5507
2342
  if (typeof e.preventDefault === 'function') e.preventDefault();
@@ -5539,34 +2374,47 @@ function createOrbit(host, cam, opts) {
5539
2374
  zoom: o.zoom ?? 1,
5540
2375
  /** Scalar on the wheel dolly. */
5541
2376
  wheel: o.wheel ?? 1,
2377
+ /** The lag's time constant, seconds; 0 makes the orbit exact. */
2378
+ damping: o.damping ?? 0.2,
5542
2379
  /** Gaze-distance clamps for every dolly. */
5543
2380
  minDistance: o.minDistance ?? 0,
5544
2381
  maxDistance: o.maxDistance ?? Infinity,
5545
2382
 
5546
2383
  /**
5547
- * Consume this frame's unclaimed pointer motion and wheel into cam.
2384
+ * Consume this frame's unclaimed pointer motion and wheel into cam —
2385
+ * through the lag when damping is on.
2386
+ * @param {number} [dt] The frame in seconds; measured since the last update when omitted.
5548
2387
  * @returns {boolean} Whether the camera moved.
5549
2388
  */
5550
- update() {
5551
- if (!orbit.enabled) { tracked.clear(); wheelAcc = 0; return false; }
2389
+ update(dt) {
2390
+ const now = _now$1();
2391
+ if (typeof dt !== 'number') dt = lastNow ? Math.min((now - lastNow) / 1000, DT_MAX) : 0;
2392
+ lastNow = now;
2393
+ if (!orbit.enabled) { tracked.clear(); wheelAcc = 0; cancel(); wasPressed = false; return false; }
5552
2394
  const src = host.pointer;
5553
2395
  let a = null, b = null;
5554
2396
  for (const p of src.pointers.values()) {
5555
- if (p.owner !== null || p.up || p.cancel) { tracked.delete(p.id); continue; }
2397
+ // a hovering mouse has an entry without a press; only pressed, unclaimed, live pointers drive
2398
+ if (!p.down || p.owner !== null || p.up || p.cancel) { tracked.delete(p.id); continue; }
5556
2399
  if (a === null || p.id < a.id) { b = a; a = p; }
5557
2400
  else if (b === null || p.id < b.id) b = p;
5558
2401
  }
5559
2402
  for (const id of tracked.keys()) {
5560
2403
  if ((a === null || id !== a.id) && (b === null || id !== b.id)) tracked.delete(id);
5561
2404
  }
2405
+ const tau = orbit.damping > 0 ? orbit.damping : 0;
2406
+ const pressed = a !== null;
2407
+ if (pressed && !wasPressed) cancel(); // a fresh touch catches the scene: what was still draining is dropped
2408
+ wasPressed = pressed;
5562
2409
  let moved = false;
2410
+ let az = 0, el = 0, px = 0, py = 0, ld = 0; // this frame's step
5563
2411
  const view = host.view;
5564
2412
  const ys = view.mat4Proj[5] < 0 ? -1 : 1; // screen-down is the eye's −up under a y-up projection, +up under p5's flip
5565
2413
  if (a !== null && b === null) {
5566
2414
  const t = tracked.get(a.id);
5567
2415
  if (t) {
5568
2416
  const dx = a.x - t.x, dy = a.y - t.y;
5569
- if (dx !== 0 || dy !== 0) { cameraOrbit(cam, -dx * orbit.rotate, ys * dy * orbit.rotate); moved = true; }
2417
+ if (dx !== 0 || dy !== 0) { az = -dx * orbit.rotate; el = ys * dy * orbit.rotate; }
5570
2418
  }
5571
2419
  track(a);
5572
2420
  } else if (a !== null && b !== null) {
@@ -5575,30 +2423,37 @@ function createOrbit(host, cam, opts) {
5575
2423
  const dmx = (a.x + b.x - ta.x - tb.x) / 2, dmy = (a.y + b.y - ta.y - tb.y) / 2;
5576
2424
  if (dmx !== 0 || dmy !== 0) {
5577
2425
  const ratio = pixelRatio(view.mat4Proj, -view.vp[3] || 1, -distance(), view.ndcZMin);
5578
- cameraPan(cam, -dmx * ratio * orbit.pan, ys * dmy * ratio * orbit.pan);
5579
- moved = true;
2426
+ px = -dmx * ratio * orbit.pan; py = ys * dmy * ratio * orbit.pan;
5580
2427
  }
5581
2428
  const d0 = Math.hypot(ta.x - tb.x, ta.y - tb.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);
5582
- if (d0 > 0 && d1 > 0 && d0 !== d1) { dolly(Math.pow(d0 / d1, orbit.zoom)); moved = true; }
2429
+ if (d0 > 0 && d1 > 0 && d0 !== d1) ld = orbit.zoom * Math.log(d0 / d1);
5583
2430
  }
5584
2431
  track(a); track(b);
5585
2432
  }
2433
+ // the step: exact, or — with damping — an impulse the drain below spreads over time
2434
+ if (az !== 0 || el !== 0 || px !== 0 || py !== 0 || ld !== 0) {
2435
+ if (tau > 0) { rate[0] += az / tau; rate[1] += el / tau; rate[2] += px / tau; rate[3] += py / tau; rate[4] += ld / tau; }
2436
+ else { step[0] = az; step[1] = el; step[2] = px; step[3] = py; step[4] = ld; apply(step); moved = true; }
2437
+ }
5586
2438
  if (wheelAcc !== 0) {
5587
- dolly(Math.exp(wheelAcc * 0.001 * orbit.wheel));
2439
+ const w = wheelAcc * 0.001 * orbit.wheel;
5588
2440
  wheelAcc = 0;
5589
- moved = true;
2441
+ if (tau > 0) rate[4] += w / tau;
2442
+ else { dolly(Math.exp(w)); moved = true; }
5590
2443
  }
2444
+ if (tau > 0 && coastAlive(rate, EPS)) { apply(coastStep(step, rate, dt, tau)); moved = true; }
5591
2445
  return moved;
5592
2446
  },
5593
2447
 
5594
- /** Restore the pose captured at creation. */
5595
- home() { cameraCopy(cam, home); return orbit; },
2448
+ /** Restore the pose captured at creation; whatever was draining is dropped. */
2449
+ home() { cameraCopy(cam, home); cancel(); return orbit; },
5596
2450
 
5597
2451
  /** Remove the wheel listener, restore touch-action, unregister from the host. */
5598
2452
  dispose() {
5599
2453
  canvas.removeEventListener('wheel', onWheel);
5600
2454
  if (canvas.style) canvas.style.touchAction = touchAction;
5601
2455
  tracked.clear();
2456
+ cancel();
5602
2457
  host.unregister(orbit);
5603
2458
  return orbit;
5604
2459
  },
@@ -5623,7 +2478,7 @@ function createOrbit(host, cam, opts) {
5623
2478
  * host.cameraHelm(cam, opts) · host.poseHelm(opts)
5624
2479
  * host.poseTrack(opts) · host.cameraTrack(cam, opts)
5625
2480
  * host.hid(opts) · host.gamepad(opts)
5626
- * host.image(url) · host.video(opts) · host.raster(draw, w, h)
2481
+ * host.image(url) · host.video(opts) · host.raster(draw, w, h) · host.model(url)
5627
2482
  * host.labels // the DOM label layer, created on first access
5628
2483
  * host.orbit(cam, opts)
5629
2484
  * host.tick(dt) // external-loop mode
@@ -5718,6 +2573,8 @@ function createHost(canvas, opts) {
5718
2573
  video(opts) { return host.register(createVideo(opts)); },
5719
2574
  /** Draw through a 2D context into a texture source — see host/media. */
5720
2575
  raster(draw, w, h, opts) { return raster(draw, w, h, opts); },
2576
+ /** Fetch an OBJ model into the arrays shape — see host/media. */
2577
+ model(url, opts) { return loadModel(url, opts); },
5721
2578
  /**
5722
2579
  * The label layer — see host/labels. Created on first access, so a
5723
2580
  * canvas that never labels never gets a layer nor a repositioned parent.
@@ -5773,5 +2630,5 @@ function createHost(canvas, opts) {
5773
2630
  return host;
5774
2631
  }
5775
2632
 
5776
- export { GAMEPAD_MAP, HID_FILTERS, Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createGamepad, createHid, createHost, createLabels, createLoop, createOrbit, createPlayers, createPointer, createVideo, createView, decodeSpaceNavigator, helmBasis, isConstraint, loadImage, measureCanvas, observeCanvas, poseHelm, poseTrack, raster, validConstraint };
2633
+ export { GAMEPAD_MAP, HID_FILTERS, Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createGamepad, createHid, createHost, createLabels, createLoop, createOrbit, createPlayers, createPointer, createVideo, createView, decodeSpaceNavigator, helmBasis, isConstraint, loadImage, loadModel, measureCanvas, observeCanvas, poseHelm, poseTrack, raster, validConstraint };
5777
2634
  //# sourceMappingURL=index.js.map