@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/README.md +5 -4
- package/dist/index.js +166 -3309
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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$
|
|
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$
|
|
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
|
|
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.
|
|
5473
|
-
*
|
|
5474
|
-
*
|
|
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).
|
|
5496
|
-
*
|
|
5497
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
2439
|
+
const w = wheelAcc * 0.001 * orbit.wheel;
|
|
5588
2440
|
wheelAcc = 0;
|
|
5589
|
-
|
|
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
|