@nakednous/host 0.0.2 → 0.0.4
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 +110 -10
- package/dist/index.js +846 -3103
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1,3104 +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 = (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(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(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(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(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(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
|
-
const _d = [0, 0, 0]; // view-direction scratch
|
|
3006
|
-
|
|
3007
|
-
/** Gaze distance |center − eye|, or 1 when the state is degenerate. */
|
|
3008
|
-
function _gaze(cam) {
|
|
3009
|
-
const dx = cam.center[0]-cam.eye[0], dy = cam.center[1]-cam.eye[1], dz = cam.center[2]-cam.eye[2];
|
|
3010
|
-
const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
|
|
3011
|
-
return d > 0 ? d : 1;
|
|
3012
|
-
}
|
|
3013
|
-
|
|
3014
|
-
// =========================================================================
|
|
3015
|
-
// Builders — state → matrices
|
|
3016
|
-
// =========================================================================
|
|
3017
|
-
|
|
3018
|
-
/**
|
|
3019
|
-
* View matrix (world→eye) from the state's lookat.
|
|
3020
|
-
* @param {Float32Array|number[]} out 16-element destination.
|
|
3021
|
-
* @param {object} cam
|
|
3022
|
-
* @returns {Float32Array|number[]} out
|
|
3023
|
-
*/
|
|
3024
|
-
function cameraView(out, cam) {
|
|
3025
|
-
const e = cam.eye, c = cam.center, u = cam.up;
|
|
3026
|
-
return mat4View(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
|
|
3027
|
-
}
|
|
3028
|
-
|
|
3029
|
-
/**
|
|
3030
|
-
* Eye matrix (eye→world) from the state's lookat.
|
|
3031
|
-
* @param {Float32Array|number[]} out 16-element destination.
|
|
3032
|
-
* @param {object} cam
|
|
3033
|
-
* @returns {Float32Array|number[]} out
|
|
3034
|
-
*/
|
|
3035
|
-
function cameraEye(out, cam) {
|
|
3036
|
-
const e = cam.eye, c = cam.center, u = cam.up;
|
|
3037
|
-
return mat4Eye(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
/**
|
|
3041
|
-
* Projection matrix from the state's lens: mat4Persp from fov, or mat4Ortho
|
|
3042
|
-
* from halfHeight, with symmetric extents (top = near · tan(fov / 2) or
|
|
3043
|
-
* halfHeight; right = top · aspect).
|
|
3044
|
-
*
|
|
3045
|
-
* @param {Float32Array|number[]} out 16-element destination.
|
|
3046
|
-
* @param {object} cam
|
|
3047
|
-
* @param {number} aspect Viewport width / height.
|
|
3048
|
-
* @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
|
|
3049
|
-
* @param {number} [ndcYSign=1] +1 NDC y-up; −1 NDC y-down.
|
|
3050
|
-
* @returns {Float32Array|number[]|null} out, or null (out untouched) when
|
|
3051
|
-
* both fov and halfHeight are null.
|
|
3052
|
-
*/
|
|
3053
|
-
function cameraProj(out, cam, aspect, ndcZMin, ndcYSign = 1) {
|
|
3054
|
-
const near = cam.near, far = cam.far;
|
|
3055
|
-
if (cam.fov != null) {
|
|
3056
|
-
const top = near * Math.tan(cam.fov / 2), right = top * aspect;
|
|
3057
|
-
return mat4Persp(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
|
|
3058
|
-
}
|
|
3059
|
-
if (cam.halfHeight != null) {
|
|
3060
|
-
const top = cam.halfHeight, right = top * aspect;
|
|
3061
|
-
return mat4Ortho(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
|
|
3062
|
-
}
|
|
3063
|
-
return null;
|
|
3064
|
-
}
|
|
3065
|
-
|
|
3066
|
-
/**
|
|
3067
|
-
* Drive the lookat from a TRS pose: eye ← pos, up and forward from the
|
|
3068
|
-
* rotation's columns 1 and −2, center ← eye + forward · d with d the current
|
|
3069
|
-
* gaze distance (1 when degenerate). The lens is untouched; `scl` is ignored.
|
|
3070
|
-
*
|
|
3071
|
-
* @param {object} cam State written in place.
|
|
3072
|
-
* @param {{ pos:number[], rot:number[] }} pose rot is a unit quaternion [x,y,z,w].
|
|
3073
|
-
* @returns {object} cam
|
|
3074
|
-
*/
|
|
3075
|
-
function cameraFromPose(cam, pose) {
|
|
3076
|
-
const d = _gaze(cam);
|
|
3077
|
-
const q = pose.rot, x=q[0], y=q[1], z=q[2], w=q[3];
|
|
3078
|
-
const x2=x+x, y2=y+y, z2=z+z;
|
|
3079
|
-
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;
|
|
3080
|
-
const ux=xy-wz, uy=1-(xx+zz), uz=yz+wx; // column 1 of qToMat4(rot): up
|
|
3081
|
-
const bx=xz+wy, by=yz-wx, bz=1-(xx+yy); // column 2: back
|
|
3082
|
-
cam.eye[0]=pose.pos[0]; cam.eye[1]=pose.pos[1]; cam.eye[2]=pose.pos[2];
|
|
3083
|
-
cam.up[0]=ux; cam.up[1]=uy; cam.up[2]=uz;
|
|
3084
|
-
cam.center[0]=cam.eye[0]-bx*d; cam.center[1]=cam.eye[1]-by*d; cam.center[2]=cam.eye[2]-bz*d;
|
|
3085
|
-
return cam;
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
/**
|
|
3089
|
-
* The lookat as a TRS pose: pos ← eye, rot ← qFromLookDir(center − eye, up) —
|
|
3090
|
-
* the rotation of cameraEye, so a helm seeded from it continues the frame.
|
|
3091
|
-
*
|
|
3092
|
-
* @param {{ pos:number[], rot:number[] }} pose Written in place.
|
|
3093
|
-
* @param {object} cam
|
|
3094
|
-
* @returns {{ pos:number[], rot:number[] }} pose
|
|
3095
|
-
*/
|
|
3096
|
-
function cameraToPose(pose, cam) {
|
|
3097
|
-
pose.pos[0]=cam.eye[0]; pose.pos[1]=cam.eye[1]; pose.pos[2]=cam.eye[2];
|
|
3098
|
-
_d[0]=cam.center[0]-cam.eye[0]; _d[1]=cam.center[1]-cam.eye[1]; _d[2]=cam.center[2]-cam.eye[2];
|
|
3099
|
-
qFromLookDir(pose.rot, _d, cam.up);
|
|
3100
|
-
return pose;
|
|
3101
|
-
}
|
|
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, cameraOrbit, cameraPan, cameraDolly } from '@nakednous/tree';
|
|
3102
2
|
|
|
3103
3
|
/**
|
|
3104
4
|
* @file The view bag — the matrices every pick, solve, label and orbit reads.
|
|
@@ -4690,6 +1590,819 @@ class TrackHandles {
|
|
|
4690
1590
|
}
|
|
4691
1591
|
}
|
|
4692
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* @file Device streams — WebHID and Gamepad rate sources that feed a helm.
|
|
1595
|
+
* @module host/stream
|
|
1596
|
+
* @license AGPL-3.0-only
|
|
1597
|
+
*
|
|
1598
|
+
* The measure-by-subscription transports. Each yields lin[3] / ang[3] in raw
|
|
1599
|
+
* device units and, once bind(helm) ran, calls helm.feed(lin, ang) every
|
|
1600
|
+
* tick of the host's players — the helm's profile does all scaling, so a
|
|
1601
|
+
* stream carries no configuration beyond identification and decoding.
|
|
1602
|
+
*
|
|
1603
|
+
* hid — WebHID. connect() must run from a user gesture; reports land
|
|
1604
|
+
* out of band into the stream's buffers and the frame reads the
|
|
1605
|
+
* latest (last-write-wins). Permissions-Policy gates
|
|
1606
|
+
* requestDevice to top-level documents: a stream in an iframe
|
|
1607
|
+
* without allow="hid" reports available === false and never
|
|
1608
|
+
* prompts.
|
|
1609
|
+
* gamepad — the Gamepad API, polled once per tick from
|
|
1610
|
+
* navigator.getGamepads(); an absent axis reads exactly 0.
|
|
1611
|
+
*
|
|
1612
|
+
* Both expose available and connected for a UI to reflect, and bind(helm),
|
|
1613
|
+
* unbind(), dispose().
|
|
1614
|
+
*/
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
const _nav = () => (typeof navigator !== 'undefined' ? navigator : null);
|
|
1618
|
+
|
|
1619
|
+
/** The 3Dconnexion vendor ids: the SpaceNavigator and its successors. */
|
|
1620
|
+
const HID_FILTERS = [{ vendorId: 0x046d }, { vendorId: 0x256f }];
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* The SpaceNavigator decode: report 1 carries translation, report 2
|
|
1624
|
+
* rotation, each three int16 little-endian lanes; a firmware that packs all
|
|
1625
|
+
* six lanes into one report fills both halves from it.
|
|
1626
|
+
* @param {DataView} report The report's data (the id byte excluded).
|
|
1627
|
+
* @param {number} reportId
|
|
1628
|
+
* @param {number[]} lin Translation lanes, written.
|
|
1629
|
+
* @param {number[]} ang Rotation lanes, written.
|
|
1630
|
+
*/
|
|
1631
|
+
function decodeSpaceNavigator(report, reportId, lin, ang) {
|
|
1632
|
+
if (report.byteLength >= 12) {
|
|
1633
|
+
for (let i = 0; i < 3; i++) { lin[i] = report.getInt16(2 * i, true); ang[i] = report.getInt16(6 + 2 * i, true); }
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (report.byteLength < 6) return;
|
|
1637
|
+
const t = reportId === 1 ? lin : reportId === 2 ? ang : null;
|
|
1638
|
+
if (!t) return;
|
|
1639
|
+
for (let i = 0; i < 3; i++) t[i] = report.getInt16(2 * i, true);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Whether WebHID's requestDevice is allowed in this document. */
|
|
1643
|
+
function _hidAllowed() {
|
|
1644
|
+
if (typeof document !== 'undefined' && document.featurePolicy && typeof document.featurePolicy.allowsFeature === 'function') {
|
|
1645
|
+
return document.featurePolicy.allowsFeature('hid');
|
|
1646
|
+
}
|
|
1647
|
+
if (typeof window !== 'undefined' && 'top' in window) return window.top === window.self;
|
|
1648
|
+
return true;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function _stream(host, lin, ang) {
|
|
1652
|
+
let helm = null;
|
|
1653
|
+
const s = {
|
|
1654
|
+
/** The latest translation lanes, raw device units. */
|
|
1655
|
+
lin,
|
|
1656
|
+
/** The latest rotation lanes, raw device units. */
|
|
1657
|
+
ang,
|
|
1658
|
+
/** The bound helm, or null. */
|
|
1659
|
+
get helm() { return helm; },
|
|
1660
|
+
/**
|
|
1661
|
+
* Feed a helm every tick. Chainable.
|
|
1662
|
+
* @param {object} target A PoseHelm (anything with feed(lin, ang)).
|
|
1663
|
+
* @returns {object} this
|
|
1664
|
+
*/
|
|
1665
|
+
bind(target) {
|
|
1666
|
+
if (!target || typeof target.feed !== 'function') {
|
|
1667
|
+
console.error('[host] stream: bind() target must have feed(lin, ang). Leaving unbound.');
|
|
1668
|
+
return s;
|
|
1669
|
+
}
|
|
1670
|
+
helm = target;
|
|
1671
|
+
return s;
|
|
1672
|
+
},
|
|
1673
|
+
/** Stop feeding; the helm sees its last rate until it is fed again. */
|
|
1674
|
+
unbind() { helm = null; return s; },
|
|
1675
|
+
};
|
|
1676
|
+
const feed = () => { if (helm) helm.feed(lin, ang); };
|
|
1677
|
+
return { s, feed };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* A WebHID rate stream.
|
|
1682
|
+
*
|
|
1683
|
+
* @param {object} host
|
|
1684
|
+
* @param {{ filters?:object[], decode?:function, bind?:object, resume?:boolean,
|
|
1685
|
+
* hid?:object }} [opts]
|
|
1686
|
+
* filters: the requestDevice filters (default HID_FILTERS). decode:
|
|
1687
|
+
* decode(report, reportId, lin, ang) (default decodeSpaceNavigator).
|
|
1688
|
+
* bind: a helm to bind at once. resume (default true): attach a device
|
|
1689
|
+
* this origin was already granted, without a prompt. hid: the WebHID
|
|
1690
|
+
* interface (default navigator.hid) — the seam a test fills.
|
|
1691
|
+
* @returns {object} The stream: { available, connected, device, lin, ang,
|
|
1692
|
+
* connect(), bind(helm), unbind(), dispose() }.
|
|
1693
|
+
*/
|
|
1694
|
+
function createHid(host, opts) {
|
|
1695
|
+
const o = opts || {};
|
|
1696
|
+
const hid = o.hid || (_nav() && _nav().hid) || null;
|
|
1697
|
+
const filters = o.filters || HID_FILTERS;
|
|
1698
|
+
const decode = typeof o.decode === 'function' ? o.decode : decodeSpaceNavigator;
|
|
1699
|
+
const lin = [0, 0, 0], ang = [0, 0, 0];
|
|
1700
|
+
const { s, feed } = _stream(host, lin, ang);
|
|
1701
|
+
let device = null;
|
|
1702
|
+
|
|
1703
|
+
const onReport = (e) => decode(e.data, e.reportId, lin, ang);
|
|
1704
|
+
const onDisconnect = (e) => { if (device && e.device === device) detach(); };
|
|
1705
|
+
const matches = (d) => filters.length === 0 || filters.some((f) =>
|
|
1706
|
+
(f.vendorId == null || f.vendorId === d.vendorId) && (f.productId == null || f.productId === d.productId));
|
|
1707
|
+
|
|
1708
|
+
function detach() {
|
|
1709
|
+
if (!device) return;
|
|
1710
|
+
device.removeEventListener('inputreport', onReport);
|
|
1711
|
+
if (device.opened && typeof device.close === 'function') device.close().catch(() => {});
|
|
1712
|
+
device = null;
|
|
1713
|
+
s.connected = false;
|
|
1714
|
+
lin[0] = lin[1] = lin[2] = ang[0] = ang[1] = ang[2] = 0;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function attach(d) {
|
|
1718
|
+
detach();
|
|
1719
|
+
try { if (!d.opened) await d.open(); }
|
|
1720
|
+
catch (e) { console.error('[host] hid: open() failed: ' + e.message + '.'); return false; }
|
|
1721
|
+
device = d;
|
|
1722
|
+
d.addEventListener('inputreport', onReport);
|
|
1723
|
+
s.connected = true;
|
|
1724
|
+
return true;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
Object.assign(s, {
|
|
1728
|
+
/** Whether WebHID exists and requestDevice is allowed here. */
|
|
1729
|
+
available: !!(hid && typeof hid.requestDevice === 'function') && _hidAllowed(),
|
|
1730
|
+
/** Whether a device is open and reporting. */
|
|
1731
|
+
connected: false,
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Prompt for a device (a user gesture is required) and attach the choice.
|
|
1735
|
+
* @returns {Promise<boolean>} attached
|
|
1736
|
+
*/
|
|
1737
|
+
async connect() {
|
|
1738
|
+
if (!s.available) { console.error('[host] hid: WebHID is unavailable here (a top-level Chromium document over https or localhost).'); return false; }
|
|
1739
|
+
let devices;
|
|
1740
|
+
try { devices = await hid.requestDevice({ filters }); }
|
|
1741
|
+
catch (e) { console.error('[host] hid: requestDevice rejected: ' + e.message + '.'); return false; }
|
|
1742
|
+
if (!devices || !devices.length) return false;
|
|
1743
|
+
return attach(devices[0]);
|
|
1744
|
+
},
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Attach a device this origin was already granted, without a prompt.
|
|
1748
|
+
* @returns {Promise<boolean>} attached
|
|
1749
|
+
*/
|
|
1750
|
+
async resume() {
|
|
1751
|
+
if (!hid || typeof hid.getDevices !== 'function') return false;
|
|
1752
|
+
let devices;
|
|
1753
|
+
try { devices = await hid.getDevices(); } catch (_) { return false; }
|
|
1754
|
+
const d = (devices || []).find(matches);
|
|
1755
|
+
return d ? attach(d) : false;
|
|
1756
|
+
},
|
|
1757
|
+
|
|
1758
|
+
/** Close the device, remove every listener, unregister from the host. */
|
|
1759
|
+
dispose() {
|
|
1760
|
+
detach();
|
|
1761
|
+
if (hid && typeof hid.removeEventListener === 'function') hid.removeEventListener('disconnect', onDisconnect);
|
|
1762
|
+
host.players.remove(player);
|
|
1763
|
+
host.unregister(s);
|
|
1764
|
+
return s;
|
|
1765
|
+
},
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
/** The open HIDDevice, or null. */
|
|
1769
|
+
Object.defineProperty(s, 'device', { get: () => device, enumerable: true });
|
|
1770
|
+
|
|
1771
|
+
const player = { tick() { feed(); return true; } };
|
|
1772
|
+
host.players.add(player);
|
|
1773
|
+
if (hid && typeof hid.addEventListener === 'function') hid.addEventListener('disconnect', onDisconnect);
|
|
1774
|
+
if (o.bind) s.bind(o.bind);
|
|
1775
|
+
if (s.available && o.resume !== false) s.resume();
|
|
1776
|
+
return host.register(s);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* The default gamepad map, the standard layout: left stick → lin[0] / lin[1],
|
|
1781
|
+
* right stick → ang[1] / ang[0], the triggers → lin[2] (right minus left).
|
|
1782
|
+
*/
|
|
1783
|
+
const GAMEPAD_MAP = { lin: [0, 1, { buttons: [6, 7] }], ang: [3, 2, null] };
|
|
1784
|
+
|
|
1785
|
+
function _lane(gp, spec) {
|
|
1786
|
+
if (spec == null) return 0;
|
|
1787
|
+
if (typeof spec === 'number') { const v = gp.axes[spec]; return typeof v === 'number' ? v : 0; }
|
|
1788
|
+
if (spec.buttons) {
|
|
1789
|
+
const n = gp.buttons[spec.buttons[0]], p = gp.buttons[spec.buttons[1]];
|
|
1790
|
+
return (p ? p.value : 0) - (n ? n.value : 0);
|
|
1791
|
+
}
|
|
1792
|
+
if (spec.button) { const b = gp.buttons[spec.button]; return b ? b.value : 0; }
|
|
1793
|
+
return 0;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* A Gamepad rate stream, polled once per tick.
|
|
1798
|
+
*
|
|
1799
|
+
* @param {object} host
|
|
1800
|
+
* @param {{ index?:number, map?:object, bind?:object, gamepads?:function }} [opts]
|
|
1801
|
+
* index: which gamepad (default: the first connected). map: { lin,
|
|
1802
|
+
* ang }, each three lane specs — an axis index, { buttons: [neg,
|
|
1803
|
+
* pos] }, { button } or null (default GAMEPAD_MAP). bind: a helm to
|
|
1804
|
+
* bind at once. gamepads: the poll (default navigator.getGamepads) —
|
|
1805
|
+
* the seam a test fills.
|
|
1806
|
+
* @returns {object} The stream: { available, connected, index, lin, ang,
|
|
1807
|
+
* bind(helm), unbind(), dispose() }.
|
|
1808
|
+
*/
|
|
1809
|
+
function createGamepad(host, opts) {
|
|
1810
|
+
const o = opts || {};
|
|
1811
|
+
const nav = _nav();
|
|
1812
|
+
const poll = o.gamepads || (nav && typeof nav.getGamepads === 'function' ? () => nav.getGamepads() : null);
|
|
1813
|
+
const map = o.map || GAMEPAD_MAP;
|
|
1814
|
+
const lin = [0, 0, 0], ang = [0, 0, 0];
|
|
1815
|
+
const { s, feed } = _stream(host, lin, ang);
|
|
1816
|
+
|
|
1817
|
+
Object.assign(s, {
|
|
1818
|
+
/** Whether the Gamepad API exists. */
|
|
1819
|
+
available: !!poll,
|
|
1820
|
+
/** Whether the last poll found a gamepad. */
|
|
1821
|
+
connected: false,
|
|
1822
|
+
/** The gamepad index polled, or null for the first connected. */
|
|
1823
|
+
index: o.index ?? null,
|
|
1824
|
+
/** Stop polling, unregister from the host. */
|
|
1825
|
+
dispose() { host.players.remove(player); host.unregister(s); return s; },
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
const player = {
|
|
1829
|
+
tick() {
|
|
1830
|
+
let gp = null;
|
|
1831
|
+
if (poll) {
|
|
1832
|
+
const list = poll() || [];
|
|
1833
|
+
if (s.index != null) gp = list[s.index] || null;
|
|
1834
|
+
else for (let i = 0; i < list.length; i++) if (list[i]) { gp = list[i]; break; }
|
|
1835
|
+
}
|
|
1836
|
+
s.connected = !!gp;
|
|
1837
|
+
for (let i = 0; i < 3; i++) {
|
|
1838
|
+
lin[i] = gp ? _lane(gp, map.lin[i]) : 0;
|
|
1839
|
+
ang[i] = gp ? _lane(gp, map.ang[i]) : 0;
|
|
1840
|
+
}
|
|
1841
|
+
feed();
|
|
1842
|
+
return true;
|
|
1843
|
+
},
|
|
1844
|
+
};
|
|
1845
|
+
host.players.add(player);
|
|
1846
|
+
if (o.bind) s.bind(o.bind);
|
|
1847
|
+
return host.register(s);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function getDefaultExportFromCjs (x) {
|
|
1851
|
+
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
var webglObjLoader_min$1 = {exports: {}};
|
|
1855
|
+
|
|
1856
|
+
var webglObjLoader_min = webglObjLoader_min$1.exports;
|
|
1857
|
+
|
|
1858
|
+
var hasRequiredWebglObjLoader_min;
|
|
1859
|
+
|
|
1860
|
+
function requireWebglObjLoader_min () {
|
|
1861
|
+
if (hasRequiredWebglObjLoader_min) return webglObjLoader_min$1.exports;
|
|
1862
|
+
hasRequiredWebglObjLoader_min = 1;
|
|
1863
|
+
(function (module, exports) {
|
|
1864
|
+
!function(e,t){module.exports=t();}("undefined"!=typeof self?self:webglObjLoader_min,(function(){return function(e){var t={};function n(a){if(t[a])return t[a].exports;var s=t[a]={i:a,l:false,exports:{}};return e[a].call(s.exports,s,s.exports,n),s.l=true,s.exports}return n.m=e,n.c=t,n.d=function(e,t,a){n.o(e,t)||Object.defineProperty(e,t,{enumerable:true,get:a});},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:true});},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var a=Object.create(null);if(n.r(a),Object.defineProperty(a,"default",{enumerable:true,value:e}),2&t&&"string"!=typeof e)for(var s in e)n.d(a,s,function(t){return e[t]}.bind(null,s));return a},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=0)}({"./src/index.ts":
|
|
1865
|
+
/*!**********************!*\
|
|
1866
|
+
!*** ./src/index.ts ***!
|
|
1867
|
+
\**********************/
|
|
1868
|
+
/*! exports provided: OBJ, Attribute, DuplicateAttributeException, Layout, Material, MaterialLibrary, Mesh, TYPES, downloadModels, downloadMeshes, initMeshBuffers, deleteMeshBuffers, version */function(module,__webpack_exports__,__webpack_require__){eval('__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "OBJ", function() { return OBJ; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "version", function() { return version; });\n/* harmony import */ var _mesh__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./mesh */ "./src/mesh.ts");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Mesh", function() { return _mesh__WEBPACK_IMPORTED_MODULE_0__["default"]; });\n\n/* harmony import */ var _material__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./material */ "./src/material.ts");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Material", function() { return _material__WEBPACK_IMPORTED_MODULE_1__["Material"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MaterialLibrary", function() { return _material__WEBPACK_IMPORTED_MODULE_1__["MaterialLibrary"]; });\n\n/* harmony import */ var _layout__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./layout */ "./src/layout.ts");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Attribute", function() { return _layout__WEBPACK_IMPORTED_MODULE_2__["Attribute"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "DuplicateAttributeException", function() { return _layout__WEBPACK_IMPORTED_MODULE_2__["DuplicateAttributeException"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Layout", function() { return _layout__WEBPACK_IMPORTED_MODULE_2__["Layout"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TYPES", function() { return _layout__WEBPACK_IMPORTED_MODULE_2__["TYPES"]; });\n\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./utils */ "./src/utils.ts");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "downloadModels", function() { return _utils__WEBPACK_IMPORTED_MODULE_3__["downloadModels"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "downloadMeshes", function() { return _utils__WEBPACK_IMPORTED_MODULE_3__["downloadMeshes"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "initMeshBuffers", function() { return _utils__WEBPACK_IMPORTED_MODULE_3__["initMeshBuffers"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "deleteMeshBuffers", function() { return _utils__WEBPACK_IMPORTED_MODULE_3__["deleteMeshBuffers"]; });\n\n\n\n\n\nconst version = "2.0.3";\nconst OBJ = {\n Attribute: _layout__WEBPACK_IMPORTED_MODULE_2__["Attribute"],\n DuplicateAttributeException: _layout__WEBPACK_IMPORTED_MODULE_2__["DuplicateAttributeException"],\n Layout: _layout__WEBPACK_IMPORTED_MODULE_2__["Layout"],\n Material: _material__WEBPACK_IMPORTED_MODULE_1__["Material"],\n MaterialLibrary: _material__WEBPACK_IMPORTED_MODULE_1__["MaterialLibrary"],\n Mesh: _mesh__WEBPACK_IMPORTED_MODULE_0__["default"],\n TYPES: _layout__WEBPACK_IMPORTED_MODULE_2__["TYPES"],\n downloadModels: _utils__WEBPACK_IMPORTED_MODULE_3__["downloadModels"],\n downloadMeshes: _utils__WEBPACK_IMPORTED_MODULE_3__["downloadMeshes"],\n initMeshBuffers: _utils__WEBPACK_IMPORTED_MODULE_3__["initMeshBuffers"],\n deleteMeshBuffers: _utils__WEBPACK_IMPORTED_MODULE_3__["deleteMeshBuffers"],\n version,\n};\n/**\n * @namespace\n */\n\n\n\n//# sourceURL=webpack:///./src/index.ts?');},"./src/layout.ts":
|
|
1869
|
+
/*!***********************!*\
|
|
1870
|
+
!*** ./src/layout.ts ***!
|
|
1871
|
+
\***********************/
|
|
1872
|
+
/*! exports provided: TYPES, DuplicateAttributeException, Attribute, Layout */function(module,__webpack_exports__,__webpack_require__){eval('__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TYPES", function() { return TYPES; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DuplicateAttributeException", function() { return DuplicateAttributeException; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Attribute", function() { return Attribute; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Layout", function() { return Layout; });\nvar TYPES;\n(function (TYPES) {\n TYPES["BYTE"] = "BYTE";\n TYPES["UNSIGNED_BYTE"] = "UNSIGNED_BYTE";\n TYPES["SHORT"] = "SHORT";\n TYPES["UNSIGNED_SHORT"] = "UNSIGNED_SHORT";\n TYPES["FLOAT"] = "FLOAT";\n})(TYPES || (TYPES = {}));\n/**\n * An exception for when two or more of the same attributes are found in the\n * same layout.\n * @private\n */\nclass DuplicateAttributeException extends Error {\n /**\n * Create a DuplicateAttributeException\n * @param {Attribute} attribute - The attribute that was found more than\n * once in the {@link Layout}\n */\n constructor(attribute) {\n super(`found duplicate attribute: ${attribute.key}`);\n }\n}\n/**\n * Represents how a vertex attribute should be packed into an buffer.\n * @private\n */\nclass Attribute {\n /**\n * Create an attribute. Do not call this directly, use the predefined\n * constants.\n * @param {string} key - The name of this attribute as if it were a key in\n * an Object. Use the camel case version of the upper snake case\n * const name.\n * @param {number} size - The number of components per vertex attribute.\n * Must be 1, 2, 3, or 4.\n * @param {string} type - The data type of each component for this\n * attribute. Possible values:<br/>\n * "BYTE": signed 8-bit integer, with values in [-128, 127]<br/>\n * "SHORT": signed 16-bit integer, with values in\n * [-32768, 32767]<br/>\n * "UNSIGNED_BYTE": unsigned 8-bit integer, with values in\n * [0, 255]<br/>\n * "UNSIGNED_SHORT": unsigned 16-bit integer, with values in\n * [0, 65535]<br/>\n * "FLOAT": 32-bit floating point number\n * @param {boolean} normalized - Whether integer data values should be\n * normalized when being casted to a float.<br/>\n * If true, signed integers are normalized to [-1, 1].<br/>\n * If true, unsigned integers are normalized to [0, 1].<br/>\n * For type "FLOAT", this parameter has no effect.\n */\n constructor(key, size, type, normalized = false) {\n this.key = key;\n this.size = size;\n this.type = type;\n this.normalized = normalized;\n switch (type) {\n case "BYTE":\n case "UNSIGNED_BYTE":\n this.sizeOfType = 1;\n break;\n case "SHORT":\n case "UNSIGNED_SHORT":\n this.sizeOfType = 2;\n break;\n case "FLOAT":\n this.sizeOfType = 4;\n break;\n default:\n throw new Error(`Unknown gl type: ${type}`);\n }\n this.sizeInBytes = this.sizeOfType * size;\n }\n}\n/**\n * A class to represent the memory layout for a vertex attribute array. Used by\n * {@link Mesh}\'s TBD(...) method to generate a packed array from mesh data.\n * <p>\n * Layout can sort of be thought of as a C-style struct declaration.\n * {@link Mesh}\'s TBD(...) method will use the {@link Layout} instance to\n * pack an array in the given attribute order.\n * <p>\n * Layout also is very helpful when calling a WebGL context\'s\n * <code>vertexAttribPointer</code> method. If you\'ve created a buffer using\n * a Layout instance, then the same Layout instance can be used to determine\n * the size, type, normalized, stride, and offset parameters for\n * <code>vertexAttribPointer</code>.\n * <p>\n * For example:\n * <pre><code>\n *\n * const index = glctx.getAttribLocation(shaderProgram, "pos");\n * glctx.vertexAttribPointer(\n * layout.position.size,\n * glctx[layout.position.type],\n * layout.position.normalized,\n * layout.position.stride,\n * layout.position.offset);\n * </code></pre>\n * @see {@link Mesh}\n */\nclass Layout {\n /**\n * Create a Layout object. This constructor will throw if any duplicate\n * attributes are given.\n * @param {Array} ...attributes - An ordered list of attributes that\n * describe the desired memory layout for each vertex attribute.\n * <p>\n *\n * @see {@link Mesh}\n */\n constructor(...attributes) {\n this.attributes = attributes;\n this.attributeMap = {};\n let offset = 0;\n let maxStrideMultiple = 0;\n for (const attribute of attributes) {\n if (this.attributeMap[attribute.key]) {\n throw new DuplicateAttributeException(attribute);\n }\n // Add padding to satisfy WebGL\'s requirement that all\n // vertexAttribPointer calls have an offset that is a multiple of\n // the type size.\n if (offset % attribute.sizeOfType !== 0) {\n offset += attribute.sizeOfType - (offset % attribute.sizeOfType);\n console.warn("Layout requires padding before " + attribute.key + " attribute");\n }\n this.attributeMap[attribute.key] = {\n attribute: attribute,\n size: attribute.size,\n type: attribute.type,\n normalized: attribute.normalized,\n offset: offset,\n };\n offset += attribute.sizeInBytes;\n maxStrideMultiple = Math.max(maxStrideMultiple, attribute.sizeOfType);\n }\n // Add padding to the end to satisfy WebGL\'s requirement that all\n // vertexAttribPointer calls have a stride that is a multiple of the\n // type size. Because we\'re putting differently sized attributes into\n // the same buffer, it must be padded to a multiple of the largest\n // type size.\n if (offset % maxStrideMultiple !== 0) {\n offset += maxStrideMultiple - (offset % maxStrideMultiple);\n console.warn("Layout requires padding at the back");\n }\n this.stride = offset;\n for (const attribute of attributes) {\n this.attributeMap[attribute.key].stride = this.stride;\n }\n }\n}\n// Geometry attributes\n/**\n * Attribute layout to pack a vertex\'s x, y, & z as floats\n *\n * @see {@link Layout}\n */\nLayout.POSITION = new Attribute("position", 3, TYPES.FLOAT);\n/**\n * Attribute layout to pack a vertex\'s normal\'s x, y, & z as floats\n *\n * @see {@link Layout}\n */\nLayout.NORMAL = new Attribute("normal", 3, TYPES.FLOAT);\n/**\n * Attribute layout to pack a vertex\'s normal\'s x, y, & z as floats.\n * <p>\n * This value will be computed on-the-fly based on the texture coordinates.\n * If no texture coordinates are available, the generated value will default to\n * 0, 0, 0.\n *\n * @see {@link Layout}\n */\nLayout.TANGENT = new Attribute("tangent", 3, TYPES.FLOAT);\n/**\n * Attribute layout to pack a vertex\'s normal\'s bitangent x, y, & z as floats.\n * <p>\n * This value will be computed on-the-fly based on the texture coordinates.\n * If no texture coordinates are available, the generated value will default to\n * 0, 0, 0.\n * @see {@link Layout}\n */\nLayout.BITANGENT = new Attribute("bitangent", 3, TYPES.FLOAT);\n/**\n * Attribute layout to pack a vertex\'s texture coordinates\' u & v as floats\n *\n * @see {@link Layout}\n */\nLayout.UV = new Attribute("uv", 2, TYPES.FLOAT);\n// Material attributes\n/**\n * Attribute layout to pack an unsigned short to be interpreted as a the index\n * into a {@link Mesh}\'s materials list.\n * <p>\n * The intention of this value is to send all of the {@link Mesh}\'s materials\n * into multiple shader uniforms and then reference the current one by this\n * vertex attribute.\n * <p>\n * example glsl code:\n *\n * <pre><code>\n * // this is bound using MATERIAL_INDEX\n * attribute int materialIndex;\n *\n * struct Material {\n * vec3 diffuse;\n * vec3 specular;\n * vec3 specularExponent;\n * };\n *\n * uniform Material materials[MAX_MATERIALS];\n *\n * // ...\n *\n * vec3 diffuse = materials[materialIndex];\n *\n * </code></pre>\n * TODO: More description & test to make sure subscripting by attributes even\n * works for webgl\n *\n * @see {@link Layout}\n */\nLayout.MATERIAL_INDEX = new Attribute("materialIndex", 1, TYPES.SHORT);\nLayout.MATERIAL_ENABLED = new Attribute("materialEnabled", 1, TYPES.UNSIGNED_SHORT);\nLayout.AMBIENT = new Attribute("ambient", 3, TYPES.FLOAT);\nLayout.DIFFUSE = new Attribute("diffuse", 3, TYPES.FLOAT);\nLayout.SPECULAR = new Attribute("specular", 3, TYPES.FLOAT);\nLayout.SPECULAR_EXPONENT = new Attribute("specularExponent", 3, TYPES.FLOAT);\nLayout.EMISSIVE = new Attribute("emissive", 3, TYPES.FLOAT);\nLayout.TRANSMISSION_FILTER = new Attribute("transmissionFilter", 3, TYPES.FLOAT);\nLayout.DISSOLVE = new Attribute("dissolve", 1, TYPES.FLOAT);\nLayout.ILLUMINATION = new Attribute("illumination", 1, TYPES.UNSIGNED_SHORT);\nLayout.REFRACTION_INDEX = new Attribute("refractionIndex", 1, TYPES.FLOAT);\nLayout.SHARPNESS = new Attribute("sharpness", 1, TYPES.FLOAT);\nLayout.MAP_DIFFUSE = new Attribute("mapDiffuse", 1, TYPES.SHORT);\nLayout.MAP_AMBIENT = new Attribute("mapAmbient", 1, TYPES.SHORT);\nLayout.MAP_SPECULAR = new Attribute("mapSpecular", 1, TYPES.SHORT);\nLayout.MAP_SPECULAR_EXPONENT = new Attribute("mapSpecularExponent", 1, TYPES.SHORT);\nLayout.MAP_DISSOLVE = new Attribute("mapDissolve", 1, TYPES.SHORT);\nLayout.ANTI_ALIASING = new Attribute("antiAliasing", 1, TYPES.UNSIGNED_SHORT);\nLayout.MAP_BUMP = new Attribute("mapBump", 1, TYPES.SHORT);\nLayout.MAP_DISPLACEMENT = new Attribute("mapDisplacement", 1, TYPES.SHORT);\nLayout.MAP_DECAL = new Attribute("mapDecal", 1, TYPES.SHORT);\nLayout.MAP_EMISSIVE = new Attribute("mapEmissive", 1, TYPES.SHORT);\n\n\n//# sourceURL=webpack:///./src/layout.ts?');},"./src/material.ts":
|
|
1873
|
+
/*!*************************!*\
|
|
1874
|
+
!*** ./src/material.ts ***!
|
|
1875
|
+
\*************************/
|
|
1876
|
+
/*! exports provided: Material, MaterialLibrary */function(module,__webpack_exports__,__webpack_require__){eval('__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Material", function() { return Material; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MaterialLibrary", function() { return MaterialLibrary; });\n/**\n * The Material class.\n */\nclass Material {\n constructor(name) {\n this.name = name;\n /**\n * Constructor\n * @param {String} name the unique name of the material\n */\n // The values for the following attibutes\n // are an array of R, G, B normalized values.\n // Ka - Ambient Reflectivity\n this.ambient = [0, 0, 0];\n // Kd - Defuse Reflectivity\n this.diffuse = [0, 0, 0];\n // Ks\n this.specular = [0, 0, 0];\n // Ke\n this.emissive = [0, 0, 0];\n // Tf\n this.transmissionFilter = [0, 0, 0];\n // d\n this.dissolve = 0;\n // valid range is between 0 and 1000\n this.specularExponent = 0;\n // either d or Tr; valid values are normalized\n this.transparency = 0;\n // illum - the enum of the illumination model to use\n this.illumination = 0;\n // Ni - Set to "normal" (air).\n this.refractionIndex = 1;\n // sharpness\n this.sharpness = 0;\n // map_Kd\n this.mapDiffuse = emptyTextureOptions();\n // map_Ka\n this.mapAmbient = emptyTextureOptions();\n // map_Ks\n this.mapSpecular = emptyTextureOptions();\n // map_Ns\n this.mapSpecularExponent = emptyTextureOptions();\n // map_d\n this.mapDissolve = emptyTextureOptions();\n // map_aat\n this.antiAliasing = false;\n // map_bump or bump\n this.mapBump = emptyTextureOptions();\n // disp\n this.mapDisplacement = emptyTextureOptions();\n // decal\n this.mapDecal = emptyTextureOptions();\n // map_Ke\n this.mapEmissive = emptyTextureOptions();\n // refl - when the reflection type is a cube, there will be multiple refl\n // statements for each side of the cube. If it\'s a spherical\n // reflection, there should only ever be one.\n this.mapReflections = [];\n }\n}\nconst SENTINEL_MATERIAL = new Material("sentinel");\n/**\n * https://en.wikipedia.org/wiki/Wavefront_.obj_file\n * http://paulbourke.net/dataformats/mtl/\n */\nclass MaterialLibrary {\n constructor(data) {\n this.data = data;\n /**\n * Constructs the Material Parser\n * @param mtlData the MTL file contents\n */\n this.currentMaterial = SENTINEL_MATERIAL;\n this.materials = {};\n this.parse();\n }\n /* eslint-disable camelcase */\n /* the function names here disobey camelCase conventions\n to make parsing/routing easier. see the parse function\n documentation for more information. */\n /**\n * Creates a new Material object and adds to the registry.\n * @param tokens the tokens associated with the directive\n */\n parse_newmtl(tokens) {\n const name = tokens[0];\n // console.info(\'Parsing new Material:\', name);\n this.currentMaterial = new Material(name);\n this.materials[name] = this.currentMaterial;\n }\n /**\n * See the documenation for parse_Ka below for a better understanding.\n *\n * Given a list of possible color tokens, returns an array of R, G, and B\n * color values.\n *\n * @param tokens the tokens associated with the directive\n * @return {*} a 3 element array containing the R, G, and B values\n * of the color.\n */\n parseColor(tokens) {\n if (tokens[0] == "spectral") {\n throw new Error("The MTL parser does not support spectral curve files. You will " +\n "need to convert the MTL colors to either RGB or CIEXYZ.");\n }\n if (tokens[0] == "xyz") {\n throw new Error("The MTL parser does not currently support XYZ colors. Either convert the " +\n "XYZ values to RGB or create an issue to add support for XYZ");\n }\n // from my understanding of the spec, RGB values at this point\n // will either be 3 floats or exactly 1 float, so that\'s the check\n // that i\'m going to perform here\n if (tokens.length == 3) {\n const [x, y, z] = tokens;\n return [parseFloat(x), parseFloat(y), parseFloat(z)];\n }\n // Since tokens at this point has a length of 3, we\'re going to assume\n // it\'s exactly 1, skipping the check for 2.\n const value = parseFloat(tokens[0]);\n // in this case, all values are equivalent\n return [value, value, value];\n }\n /**\n * Parse the ambient reflectivity\n *\n * A Ka directive can take one of three forms:\n * - Ka r g b\n * - Ka spectral file.rfl\n * - Ka xyz x y z\n * These three forms are mutually exclusive in that only one\n * declaration can exist per material. It is considered a syntax\n * error otherwise.\n *\n * The "Ka" form specifies the ambient reflectivity using RGB values.\n * The "g" and "b" values are optional. If only the "r" value is\n * specified, then the "g" and "b" values are assigned the value of\n * "r". Values are normally in the range 0.0 to 1.0. Values outside\n * of this range increase or decrease the reflectivity accordingly.\n *\n * The "Ka spectral" form specifies the ambient reflectivity using a\n * spectral curve. "file.rfl" is the name of the ".rfl" file containing\n * the curve data. "factor" is an optional argument which is a multiplier\n * for the values in the .rfl file and defaults to 1.0 if not specified.\n *\n * The "Ka xyz" form specifies the ambient reflectivity using CIEXYZ values.\n * "x y z" are the values of the CIEXYZ color space. The "y" and "z" arguments\n * are optional and take on the value of the "x" component if only "x" is\n * specified. The "x y z" values are normally in the range of 0.0 to 1.0 and\n * increase or decrease ambient reflectivity accordingly outside of that\n * range.\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Ka(tokens) {\n this.currentMaterial.ambient = this.parseColor(tokens);\n }\n /**\n * Diffuse Reflectivity\n *\n * Similar to the Ka directive. Simply replace "Ka" with "Kd" and the rules\n * are the same\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Kd(tokens) {\n this.currentMaterial.diffuse = this.parseColor(tokens);\n }\n /**\n * Spectral Reflectivity\n *\n * Similar to the Ka directive. Simply replace "Ks" with "Kd" and the rules\n * are the same\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Ks(tokens) {\n this.currentMaterial.specular = this.parseColor(tokens);\n }\n /**\n * Emissive\n *\n * The amount and color of light emitted by the object.\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Ke(tokens) {\n this.currentMaterial.emissive = this.parseColor(tokens);\n }\n /**\n * Transmission Filter\n *\n * Any light passing through the object is filtered by the transmission\n * filter, which only allows specific colors to pass through. For example, Tf\n * 0 1 0 allows all of the green to pass through and filters out all of the\n * red and blue.\n *\n * Similar to the Ka directive. Simply replace "Ks" with "Tf" and the rules\n * are the same\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Tf(tokens) {\n this.currentMaterial.transmissionFilter = this.parseColor(tokens);\n }\n /**\n * Specifies the dissolve for the current material.\n *\n * Statement: d [-halo] `factor`\n *\n * Example: "d 0.5"\n *\n * The factor is the amount this material dissolves into the background. A\n * factor of 1.0 is fully opaque. This is the default when a new material is\n * created. A factor of 0.0 is fully dissolved (completely transparent).\n *\n * Unlike a real transparent material, the dissolve does not depend upon\n * material thickness nor does it have any spectral character. Dissolve works\n * on all illumination models.\n *\n * The dissolve statement allows for an optional "-halo" flag which indicates\n * that a dissolve is dependent on the surface orientation relative to the\n * viewer. For example, a sphere with the following dissolve, "d -halo 0.0",\n * will be fully dissolved at its center and will appear gradually more opaque\n * toward its edge.\n *\n * "factor" is the minimum amount of dissolve applied to the material. The\n * amount of dissolve will vary between 1.0 (fully opaque) and the specified\n * "factor". The formula is:\n *\n * dissolve = 1.0 - (N*v)(1.0-factor)\n *\n * @param tokens the tokens associated with the directive\n */\n parse_d(tokens) {\n // this ignores the -halo option as I can\'t find any documentation on what\n // it\'s supposed to be.\n this.currentMaterial.dissolve = parseFloat(tokens.pop() || "0");\n }\n /**\n * The "illum" statement specifies the illumination model to use in the\n * material. Illumination models are mathematical equations that represent\n * various material lighting and shading effects.\n *\n * The illumination number can be a number from 0 to 10. The following are\n * the list of illumination enumerations and their summaries:\n * 0. Color on and Ambient off\n * 1. Color on and Ambient on\n * 2. Highlight on\n * 3. Reflection on and Ray trace on\n * 4. Transparency: Glass on, Reflection: Ray trace on\n * 5. Reflection: Fresnel on and Ray trace on\n * 6. Transparency: Refraction on, Reflection: Fresnel off and Ray trace on\n * 7. Transparency: Refraction on, Reflection: Fresnel on and Ray trace on\n * 8. Reflection on and Ray trace off\n * 9. Transparency: Glass on, Reflection: Ray trace off\n * 10. Casts shadows onto invisible surfaces\n *\n * Example: "illum 2" to specify the "Highlight on" model\n *\n * @param tokens the tokens associated with the directive\n */\n parse_illum(tokens) {\n this.currentMaterial.illumination = parseInt(tokens[0]);\n }\n /**\n * Optical Density (AKA Index of Refraction)\n *\n * Statement: Ni `index`\n *\n * Example: Ni 1.0\n *\n * Specifies the optical density for the surface. `index` is the value\n * for the optical density. The values can range from 0.001 to 10. A value of\n * 1.0 means that light does not bend as it passes through an object.\n * Increasing the optical_density increases the amount of bending. Glass has\n * an index of refraction of about 1.5. Values of less than 1.0 produce\n * bizarre results and are not recommended\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Ni(tokens) {\n this.currentMaterial.refractionIndex = parseFloat(tokens[0]);\n }\n /**\n * Specifies the specular exponent for the current material. This defines the\n * focus of the specular highlight.\n *\n * Statement: Ns `exponent`\n *\n * Example: "Ns 250"\n *\n * `exponent` is the value for the specular exponent. A high exponent results\n * in a tight, concentrated highlight. Ns Values normally range from 0 to\n * 1000.\n *\n * @param tokens the tokens associated with the directive\n */\n parse_Ns(tokens) {\n this.currentMaterial.specularExponent = parseInt(tokens[0]);\n }\n /**\n * Specifies the sharpness of the reflections from the local reflection map.\n *\n * Statement: sharpness `value`\n *\n * Example: "sharpness 100"\n *\n * If a material does not have a local reflection map defined in its material\n * defintions, sharpness will apply to the global reflection map defined in\n * PreView.\n *\n * `value` can be a number from 0 to 1000. The default is 60. A high value\n * results in a clear reflection of objects in the reflection map.\n *\n * Tip: sharpness values greater than 100 introduce aliasing effects in\n * flat surfaces that are viewed at a sharp angle.\n *\n * @param tokens the tokens associated with the directive\n */\n parse_sharpness(tokens) {\n this.currentMaterial.sharpness = parseInt(tokens[0]);\n }\n /**\n * Parses the -cc flag and updates the options object with the values.\n *\n * @param values the values passed to the -cc flag\n * @param options the Object of all image options\n */\n parse_cc(values, options) {\n options.colorCorrection = values[0] == "on";\n }\n /**\n * Parses the -blendu flag and updates the options object with the values.\n *\n * @param values the values passed to the -blendu flag\n * @param options the Object of all image options\n */\n parse_blendu(values, options) {\n options.horizontalBlending = values[0] == "on";\n }\n /**\n * Parses the -blendv flag and updates the options object with the values.\n *\n * @param values the values passed to the -blendv flag\n * @param options the Object of all image options\n */\n parse_blendv(values, options) {\n options.verticalBlending = values[0] == "on";\n }\n /**\n * Parses the -boost flag and updates the options object with the values.\n *\n * @param values the values passed to the -boost flag\n * @param options the Object of all image options\n */\n parse_boost(values, options) {\n options.boostMipMapSharpness = parseFloat(values[0]);\n }\n /**\n * Parses the -mm flag and updates the options object with the values.\n *\n * @param values the values passed to the -mm flag\n * @param options the Object of all image options\n */\n parse_mm(values, options) {\n options.modifyTextureMap.brightness = parseFloat(values[0]);\n options.modifyTextureMap.contrast = parseFloat(values[1]);\n }\n /**\n * Parses and sets the -o, -s, and -t u, v, and w values\n *\n * @param values the values passed to the -o, -s, -t flag\n * @param {Object} option the Object of either the -o, -s, -t option\n * @param {Integer} defaultValue the Object of all image options\n */\n parse_ost(values, option, defaultValue) {\n while (values.length < 3) {\n values.push(defaultValue.toString());\n }\n option.u = parseFloat(values[0]);\n option.v = parseFloat(values[1]);\n option.w = parseFloat(values[2]);\n }\n /**\n * Parses the -o flag and updates the options object with the values.\n *\n * @param values the values passed to the -o flag\n * @param options the Object of all image options\n */\n parse_o(values, options) {\n this.parse_ost(values, options.offset, 0);\n }\n /**\n * Parses the -s flag and updates the options object with the values.\n *\n * @param values the values passed to the -s flag\n * @param options the Object of all image options\n */\n parse_s(values, options) {\n this.parse_ost(values, options.scale, 1);\n }\n /**\n * Parses the -t flag and updates the options object with the values.\n *\n * @param values the values passed to the -t flag\n * @param options the Object of all image options\n */\n parse_t(values, options) {\n this.parse_ost(values, options.turbulence, 0);\n }\n /**\n * Parses the -texres flag and updates the options object with the values.\n *\n * @param values the values passed to the -texres flag\n * @param options the Object of all image options\n */\n parse_texres(values, options) {\n options.textureResolution = parseFloat(values[0]);\n }\n /**\n * Parses the -clamp flag and updates the options object with the values.\n *\n * @param values the values passed to the -clamp flag\n * @param options the Object of all image options\n */\n parse_clamp(values, options) {\n options.clamp = values[0] == "on";\n }\n /**\n * Parses the -bm flag and updates the options object with the values.\n *\n * @param values the values passed to the -bm flag\n * @param options the Object of all image options\n */\n parse_bm(values, options) {\n options.bumpMultiplier = parseFloat(values[0]);\n }\n /**\n * Parses the -imfchan flag and updates the options object with the values.\n *\n * @param values the values passed to the -imfchan flag\n * @param options the Object of all image options\n */\n parse_imfchan(values, options) {\n options.imfChan = values[0];\n }\n /**\n * This only exists for relection maps and denotes the type of reflection.\n *\n * @param values the values passed to the -type flag\n * @param options the Object of all image options\n */\n parse_type(values, options) {\n options.reflectionType = values[0];\n }\n /**\n * Parses the texture\'s options and returns an options object with the info\n *\n * @param tokens all of the option tokens to pass to the texture\n * @return {Object} a complete object of objects to apply to the texture\n */\n parseOptions(tokens) {\n const options = emptyTextureOptions();\n let option;\n let values;\n const optionsToValues = {};\n tokens.reverse();\n while (tokens.length) {\n // token is guaranteed to exists here, hence the explicit "as"\n const token = tokens.pop();\n if (token.startsWith("-")) {\n option = token.substr(1);\n optionsToValues[option] = [];\n }\n else if (option) {\n optionsToValues[option].push(token);\n }\n }\n for (option in optionsToValues) {\n if (!optionsToValues.hasOwnProperty(option)) {\n continue;\n }\n values = optionsToValues[option];\n const optionMethod = this[`parse_${option}`];\n if (optionMethod) {\n optionMethod.bind(this)(values, options);\n }\n }\n return options;\n }\n /**\n * Parses the given texture map line.\n *\n * @param tokens all of the tokens representing the texture\n * @return a complete object of objects to apply to the texture\n */\n parseMap(tokens) {\n // according to wikipedia:\n // (https://en.wikipedia.org/wiki/Wavefront_.obj_file#Vendor_specific_alterations)\n // there is at least one vendor that places the filename before the options\n // rather than after (which is to spec). All options start with a \'-\'\n // so if the first token doesn\'t start with a \'-\', we\'re going to assume\n // it\'s the name of the map file.\n let optionsString;\n let filename = "";\n if (!tokens[0].startsWith("-")) {\n [filename, ...optionsString] = tokens;\n }\n else {\n filename = tokens.pop();\n optionsString = tokens;\n }\n const options = this.parseOptions(optionsString);\n options.filename = filename.replace(/\\\\/g, "/");\n return options;\n }\n /**\n * Parses the ambient map.\n *\n * @param tokens list of tokens for the map_Ka direcive\n */\n parse_map_Ka(tokens) {\n this.currentMaterial.mapAmbient = this.parseMap(tokens);\n }\n /**\n * Parses the diffuse map.\n *\n * @param tokens list of tokens for the map_Kd direcive\n */\n parse_map_Kd(tokens) {\n this.currentMaterial.mapDiffuse = this.parseMap(tokens);\n }\n /**\n * Parses the specular map.\n *\n * @param tokens list of tokens for the map_Ks direcive\n */\n parse_map_Ks(tokens) {\n this.currentMaterial.mapSpecular = this.parseMap(tokens);\n }\n /**\n * Parses the emissive map.\n *\n * @param tokens list of tokens for the map_Ke direcive\n */\n parse_map_Ke(tokens) {\n this.currentMaterial.mapEmissive = this.parseMap(tokens);\n }\n /**\n * Parses the specular exponent map.\n *\n * @param tokens list of tokens for the map_Ns direcive\n */\n parse_map_Ns(tokens) {\n this.currentMaterial.mapSpecularExponent = this.parseMap(tokens);\n }\n /**\n * Parses the dissolve map.\n *\n * @param tokens list of tokens for the map_d direcive\n */\n parse_map_d(tokens) {\n this.currentMaterial.mapDissolve = this.parseMap(tokens);\n }\n /**\n * Parses the anti-aliasing option.\n *\n * @param tokens list of tokens for the map_aat direcive\n */\n parse_map_aat(tokens) {\n this.currentMaterial.antiAliasing = tokens[0] == "on";\n }\n /**\n * Parses the bump map.\n *\n * @param tokens list of tokens for the map_bump direcive\n */\n parse_map_bump(tokens) {\n this.currentMaterial.mapBump = this.parseMap(tokens);\n }\n /**\n * Parses the bump map.\n *\n * @param tokens list of tokens for the bump direcive\n */\n parse_bump(tokens) {\n this.parse_map_bump(tokens);\n }\n /**\n * Parses the disp map.\n *\n * @param tokens list of tokens for the disp direcive\n */\n parse_disp(tokens) {\n this.currentMaterial.mapDisplacement = this.parseMap(tokens);\n }\n /**\n * Parses the decal map.\n *\n * @param tokens list of tokens for the map_decal direcive\n */\n parse_decal(tokens) {\n this.currentMaterial.mapDecal = this.parseMap(tokens);\n }\n /**\n * Parses the refl map.\n *\n * @param tokens list of tokens for the refl direcive\n */\n parse_refl(tokens) {\n this.currentMaterial.mapReflections.push(this.parseMap(tokens));\n }\n /**\n * Parses the MTL file.\n *\n * Iterates line by line parsing each MTL directive.\n *\n * This function expects the first token in the line\n * to be a valid MTL directive. That token is then used\n * to try and run a method on this class. parse_[directive]\n * E.g., the `newmtl` directive would try to call the method\n * parse_newmtl. Each parsing function takes in the remaining\n * list of tokens and updates the currentMaterial class with\n * the attributes provided.\n */\n parse() {\n const lines = this.data.split(/\\r?\\n/);\n for (let line of lines) {\n line = line.trim();\n if (!line || line.startsWith("#")) {\n continue;\n }\n const [directive, ...tokens] = line.split(/\\s/);\n const parseMethod = this[`parse_${directive}`];\n if (!parseMethod) {\n console.warn(`Don\'t know how to parse the directive: "${directive}"`);\n continue;\n }\n // console.log(`Parsing "${directive}" with tokens: ${tokens}`);\n parseMethod.bind(this)(tokens);\n }\n // some cleanup. These don\'t need to be exposed as public data.\n delete this.data;\n this.currentMaterial = SENTINEL_MATERIAL;\n }\n}\nfunction emptyTextureOptions() {\n return {\n colorCorrection: false,\n horizontalBlending: true,\n verticalBlending: true,\n boostMipMapSharpness: 0,\n modifyTextureMap: {\n brightness: 0,\n contrast: 1,\n },\n offset: { u: 0, v: 0, w: 0 },\n scale: { u: 1, v: 1, w: 1 },\n turbulence: { u: 0, v: 0, w: 0 },\n clamp: false,\n textureResolution: null,\n bumpMultiplier: 1,\n imfChan: null,\n filename: "",\n };\n}\n\n\n//# sourceURL=webpack:///./src/material.ts?');},"./src/mesh.ts":
|
|
1877
|
+
/*!*********************!*\
|
|
1878
|
+
!*** ./src/mesh.ts ***!
|
|
1879
|
+
\*********************/
|
|
1880
|
+
/*! exports provided: default */function(module,__webpack_exports__,__webpack_require__){eval('__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "default", function() { return Mesh; });\n/* harmony import */ var _layout__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./layout */ "./src/layout.ts");\n\n/**\n * The main Mesh class. The constructor will parse through the OBJ file data\n * and collect the vertex, vertex normal, texture, and face information. This\n * information can then be used later on when creating your VBOs. See\n * OBJ.initMeshBuffers for an example of how to use the newly created Mesh\n */\nclass Mesh {\n /**\n * Create a Mesh\n * @param {String} objectData - a string representation of an OBJ file with\n * newlines preserved.\n * @param {Object} options - a JS object containing valid options. See class\n * documentation for options.\n * @param {bool} options.enableWTextureCoord - Texture coordinates can have\n * an optional "w" coordinate after the u and v coordinates. This extra\n * value can be used in order to perform fancy transformations on the\n * textures themselves. Default is to truncate to only the u an v\n * coordinates. Passing true will provide a default value of 0 in the\n * event that any or all texture coordinates don\'t provide a w value.\n * Always use the textureStride attribute in order to determine the\n * stride length of the texture coordinates when rendering the element\n * array.\n * @param {bool} options.calcTangentsAndBitangents - Calculate the tangents\n * and bitangents when loading of the OBJ is completed. This adds two new\n * attributes to the Mesh instance: `tangents` and `bitangents`.\n */\n constructor(objectData, options) {\n this.name = "";\n this.indicesPerMaterial = [];\n this.materialsByIndex = {};\n this.tangents = [];\n this.bitangents = [];\n options = options || {};\n options.materials = options.materials || {};\n options.enableWTextureCoord = !!options.enableWTextureCoord;\n // the list of unique vertex, normal, texture, attributes\n this.vertexNormals = [];\n this.textures = [];\n // the indicies to draw the faces\n this.indices = [];\n this.textureStride = options.enableWTextureCoord ? 3 : 2;\n /*\n The OBJ file format does a sort of compression when saving a model in a\n program like Blender. There are at least 3 sections (4 including textures)\n within the file. Each line in a section begins with the same string:\n * \'v\': indicates vertex section\n * \'vn\': indicates vertex normal section\n * \'f\': indicates the faces section\n * \'vt\': indicates vertex texture section (if textures were used on the model)\n Each of the above sections (except for the faces section) is a list/set of\n unique vertices.\n\n Each line of the faces section contains a list of\n (vertex, [texture], normal) groups.\n\n **Note:** The following documentation will use a capital "V" Vertex to\n denote the above (vertex, [texture], normal) groups whereas a lowercase\n "v" vertex is used to denote an X, Y, Z coordinate.\n\n Some examples:\n // the texture index is optional, both formats are possible for models\n // without a texture applied\n f 1/25 18/46 12/31\n f 1//25 18//46 12//31\n\n // A 3 vertex face with texture indices\n f 16/92/11 14/101/22 1/69/1\n\n // A 4 vertex face\n f 16/92/11 40/109/40 38/114/38 14/101/22\n\n The first two lines are examples of a 3 vertex face without a texture applied.\n The second is an example of a 3 vertex face with a texture applied.\n The third is an example of a 4 vertex face. Note: a face can contain N\n number of vertices.\n\n Each number that appears in one of the groups is a 1-based index\n corresponding to an item from the other sections (meaning that indexing\n starts at one and *not* zero).\n\n For example:\n `f 16/92/11` is saying to\n - take the 16th element from the [v] vertex array\n - take the 92nd element from the [vt] texture array\n - take the 11th element from the [vn] normal array\n and together they make a unique vertex.\n Using all 3+ unique Vertices from the face line will produce a polygon.\n\n Now, you could just go through the OBJ file and create a new vertex for\n each face line and WebGL will draw what appears to be the same model.\n However, vertices will be overlapped and duplicated all over the place.\n\n Consider a cube in 3D space centered about the origin and each side is\n 2 units long. The front face (with the positive Z-axis pointing towards\n you) would have a Top Right vertex (looking orthogonal to its normal)\n mapped at (1,1,1) The right face would have a Top Left vertex (looking\n orthogonal to its normal) at (1,1,1) and the top face would have a Bottom\n Right vertex (looking orthogonal to its normal) at (1,1,1). Each face\n has a vertex at the same coordinates, however, three distinct vertices\n will be drawn at the same spot.\n\n To solve the issue of duplicate Vertices (the `(vertex, [texture], normal)`\n groups), while iterating through the face lines, when a group is encountered\n the whole group string (\'16/92/11\') is checked to see if it exists in the\n packed.hashindices object, and if it doesn\'t, the indices it specifies\n are used to look up each attribute in the corresponding attribute arrays\n already created. The values are then copied to the corresponding unpacked\n array (flattened to play nice with WebGL\'s ELEMENT_ARRAY_BUFFER indexing),\n the group string is added to the hashindices set and the current unpacked\n index is used as this hashindices value so that the group of elements can\n be reused. The unpacked index is incremented. If the group string already\n exists in the hashindices object, its corresponding value is the index of\n that group and is appended to the unpacked indices array.\n */\n const verts = [];\n const vertNormals = [];\n const textures = [];\n const materialNamesByIndex = [];\n const materialIndicesByName = {};\n // keep track of what material we\'ve seen last\n let currentMaterialIndex = -1;\n let currentObjectByMaterialIndex = 0;\n // unpacking stuff\n const unpacked = {\n verts: [],\n norms: [],\n textures: [],\n hashindices: {},\n indices: [[]],\n materialIndices: [],\n index: 0,\n };\n const VERTEX_RE = /^v\\s/;\n const NORMAL_RE = /^vn\\s/;\n const TEXTURE_RE = /^vt\\s/;\n const FACE_RE = /^f\\s/;\n const WHITESPACE_RE = /\\s+/;\n const USE_MATERIAL_RE = /^usemtl/;\n // array of lines separated by the newline\n const lines = objectData.split("\\n");\n for (let line of lines) {\n line = line.trim();\n if (!line || line.startsWith("#")) {\n continue;\n }\n const elements = line.split(WHITESPACE_RE);\n elements.shift();\n if (VERTEX_RE.test(line)) {\n // if this is a vertex\n verts.push(...elements);\n }\n else if (NORMAL_RE.test(line)) {\n // if this is a vertex normal\n vertNormals.push(...elements);\n }\n else if (TEXTURE_RE.test(line)) {\n let coords = elements;\n // by default, the loader will only look at the U and V\n // coordinates of the vt declaration. So, this truncates the\n // elements to only those 2 values. If W texture coordinate\n // support is enabled, then the texture coordinate is\n // expected to have three values in it.\n if (elements.length > 2 && !options.enableWTextureCoord) {\n coords = elements.slice(0, 2);\n }\n else if (elements.length === 2 && options.enableWTextureCoord) {\n // If for some reason W texture coordinate support is enabled\n // and only the U and V coordinates are given, then we supply\n // the default value of 0 so that the stride length is correct\n // when the textures are unpacked below.\n coords.push("0");\n }\n textures.push(...coords);\n }\n else if (USE_MATERIAL_RE.test(line)) {\n const materialName = elements[0];\n // check to see if we\'ve ever seen it before\n if (!(materialName in materialIndicesByName)) {\n // new material we\'ve never seen\n materialNamesByIndex.push(materialName);\n materialIndicesByName[materialName] = materialNamesByIndex.length - 1;\n // push new array into indices\n // already contains an array at index zero, don\'t add\n if (materialIndicesByName[materialName] > 0) {\n unpacked.indices.push([]);\n }\n }\n // keep track of the current material index\n currentMaterialIndex = materialIndicesByName[materialName];\n // update current index array\n currentObjectByMaterialIndex = currentMaterialIndex;\n }\n else if (FACE_RE.test(line)) {\n // if this is a face\n /*\n split this face into an array of Vertex groups\n for example:\n f 16/92/11 14/101/22 1/69/1\n becomes:\n [\'16/92/11\', \'14/101/22\', \'1/69/1\'];\n */\n const triangles = triangulate(elements);\n for (const triangle of triangles) {\n for (let j = 0, eleLen = triangle.length; j < eleLen; j++) {\n const hash = triangle[j] + "," + currentMaterialIndex;\n if (hash in unpacked.hashindices) {\n unpacked.indices[currentObjectByMaterialIndex].push(unpacked.hashindices[hash]);\n }\n else {\n /*\n Each element of the face line array is a Vertex which has its\n attributes delimited by a forward slash. This will separate\n each attribute into another array:\n \'19/92/11\'\n becomes:\n Vertex = [\'19\', \'92\', \'11\'];\n where\n Vertex[0] is the vertex index\n Vertex[1] is the texture index\n Vertex[2] is the normal index\n Think of faces having Vertices which are comprised of the\n attributes location (v), texture (vt), and normal (vn).\n */\n const vertex = triangle[j].split("/");\n // it\'s possible for faces to only specify the vertex\n // and the normal. In this case, vertex will only have\n // a length of 2 and not 3 and the normal will be the\n // second item in the list with an index of 1.\n const normalIndex = vertex.length - 1;\n /*\n The verts, textures, and vertNormals arrays each contain a\n flattend array of coordinates.\n\n Because it gets confusing by referring to Vertex and then\n vertex (both are different in my descriptions) I will explain\n what\'s going on using the vertexNormals array:\n\n vertex[2] will contain the one-based index of the vertexNormals\n section (vn). One is subtracted from this index number to play\n nice with javascript\'s zero-based array indexing.\n\n Because vertexNormal is a flattened array of x, y, z values,\n simple pointer arithmetic is used to skip to the start of the\n vertexNormal, then the offset is added to get the correct\n component: +0 is x, +1 is y, +2 is z.\n\n This same process is repeated for verts and textures.\n */\n // Vertex position\n unpacked.verts.push(+verts[(+vertex[0] - 1) * 3 + 0]);\n unpacked.verts.push(+verts[(+vertex[0] - 1) * 3 + 1]);\n unpacked.verts.push(+verts[(+vertex[0] - 1) * 3 + 2]);\n // Vertex textures\n if (textures.length) {\n const stride = options.enableWTextureCoord ? 3 : 2;\n unpacked.textures.push(+textures[(+vertex[1] - 1) * stride + 0]);\n unpacked.textures.push(+textures[(+vertex[1] - 1) * stride + 1]);\n if (options.enableWTextureCoord) {\n unpacked.textures.push(+textures[(+vertex[1] - 1) * stride + 2]);\n }\n }\n // Vertex normals\n unpacked.norms.push(+vertNormals[(+vertex[normalIndex] - 1) * 3 + 0]);\n unpacked.norms.push(+vertNormals[(+vertex[normalIndex] - 1) * 3 + 1]);\n unpacked.norms.push(+vertNormals[(+vertex[normalIndex] - 1) * 3 + 2]);\n // Vertex material indices\n unpacked.materialIndices.push(currentMaterialIndex);\n // add the newly created Vertex to the list of indices\n unpacked.hashindices[hash] = unpacked.index;\n unpacked.indices[currentObjectByMaterialIndex].push(unpacked.hashindices[hash]);\n // increment the counter\n unpacked.index += 1;\n }\n }\n }\n }\n }\n this.vertices = unpacked.verts;\n this.vertexNormals = unpacked.norms;\n this.textures = unpacked.textures;\n this.vertexMaterialIndices = unpacked.materialIndices;\n this.indices = unpacked.indices[currentObjectByMaterialIndex];\n this.indicesPerMaterial = unpacked.indices;\n this.materialNames = materialNamesByIndex;\n this.materialIndices = materialIndicesByName;\n this.materialsByIndex = {};\n if (options.calcTangentsAndBitangents) {\n this.calculateTangentsAndBitangents();\n }\n }\n /**\n * Calculates the tangents and bitangents of the mesh that forms an orthogonal basis together with the\n * normal in the direction of the texture coordinates. These are useful for setting up the TBN matrix\n * when distorting the normals through normal maps.\n * Method derived from: http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-13-normal-mapping/\n *\n * This method requires the normals and texture coordinates to be parsed and set up correctly.\n * Adds the tangents and bitangents as members of the class instance.\n */\n calculateTangentsAndBitangents() {\n console.assert(!!(this.vertices &&\n this.vertices.length &&\n this.vertexNormals &&\n this.vertexNormals.length &&\n this.textures &&\n this.textures.length), "Missing attributes for calculating tangents and bitangents");\n const unpacked = {\n tangents: [...new Array(this.vertices.length)].map(_ => 0),\n bitangents: [...new Array(this.vertices.length)].map(_ => 0),\n };\n // Loop through all faces in the whole mesh\n const indices = this.indices;\n const vertices = this.vertices;\n const normals = this.vertexNormals;\n const uvs = this.textures;\n for (let i = 0; i < indices.length; i += 3) {\n const i0 = indices[i + 0];\n const i1 = indices[i + 1];\n const i2 = indices[i + 2];\n const x_v0 = vertices[i0 * 3 + 0];\n const y_v0 = vertices[i0 * 3 + 1];\n const z_v0 = vertices[i0 * 3 + 2];\n const x_uv0 = uvs[i0 * 2 + 0];\n const y_uv0 = uvs[i0 * 2 + 1];\n const x_v1 = vertices[i1 * 3 + 0];\n const y_v1 = vertices[i1 * 3 + 1];\n const z_v1 = vertices[i1 * 3 + 2];\n const x_uv1 = uvs[i1 * 2 + 0];\n const y_uv1 = uvs[i1 * 2 + 1];\n const x_v2 = vertices[i2 * 3 + 0];\n const y_v2 = vertices[i2 * 3 + 1];\n const z_v2 = vertices[i2 * 3 + 2];\n const x_uv2 = uvs[i2 * 2 + 0];\n const y_uv2 = uvs[i2 * 2 + 1];\n const x_deltaPos1 = x_v1 - x_v0;\n const y_deltaPos1 = y_v1 - y_v0;\n const z_deltaPos1 = z_v1 - z_v0;\n const x_deltaPos2 = x_v2 - x_v0;\n const y_deltaPos2 = y_v2 - y_v0;\n const z_deltaPos2 = z_v2 - z_v0;\n const x_uvDeltaPos1 = x_uv1 - x_uv0;\n const y_uvDeltaPos1 = y_uv1 - y_uv0;\n const x_uvDeltaPos2 = x_uv2 - x_uv0;\n const y_uvDeltaPos2 = y_uv2 - y_uv0;\n const rInv = x_uvDeltaPos1 * y_uvDeltaPos2 - y_uvDeltaPos1 * x_uvDeltaPos2;\n const r = 1.0 / Math.abs(rInv < 0.0001 ? 1.0 : rInv);\n // Tangent\n const x_tangent = (x_deltaPos1 * y_uvDeltaPos2 - x_deltaPos2 * y_uvDeltaPos1) * r;\n const y_tangent = (y_deltaPos1 * y_uvDeltaPos2 - y_deltaPos2 * y_uvDeltaPos1) * r;\n const z_tangent = (z_deltaPos1 * y_uvDeltaPos2 - z_deltaPos2 * y_uvDeltaPos1) * r;\n // Bitangent\n const x_bitangent = (x_deltaPos2 * x_uvDeltaPos1 - x_deltaPos1 * x_uvDeltaPos2) * r;\n const y_bitangent = (y_deltaPos2 * x_uvDeltaPos1 - y_deltaPos1 * x_uvDeltaPos2) * r;\n const z_bitangent = (z_deltaPos2 * x_uvDeltaPos1 - z_deltaPos1 * x_uvDeltaPos2) * r;\n // Gram-Schmidt orthogonalize\n //t = glm::normalize(t - n * glm:: dot(n, t));\n const x_n0 = normals[i0 * 3 + 0];\n const y_n0 = normals[i0 * 3 + 1];\n const z_n0 = normals[i0 * 3 + 2];\n const x_n1 = normals[i1 * 3 + 0];\n const y_n1 = normals[i1 * 3 + 1];\n const z_n1 = normals[i1 * 3 + 2];\n const x_n2 = normals[i2 * 3 + 0];\n const y_n2 = normals[i2 * 3 + 1];\n const z_n2 = normals[i2 * 3 + 2];\n // Tangent\n const n0_dot_t = x_tangent * x_n0 + y_tangent * y_n0 + z_tangent * z_n0;\n const n1_dot_t = x_tangent * x_n1 + y_tangent * y_n1 + z_tangent * z_n1;\n const n2_dot_t = x_tangent * x_n2 + y_tangent * y_n2 + z_tangent * z_n2;\n const x_resTangent0 = x_tangent - x_n0 * n0_dot_t;\n const y_resTangent0 = y_tangent - y_n0 * n0_dot_t;\n const z_resTangent0 = z_tangent - z_n0 * n0_dot_t;\n const x_resTangent1 = x_tangent - x_n1 * n1_dot_t;\n const y_resTangent1 = y_tangent - y_n1 * n1_dot_t;\n const z_resTangent1 = z_tangent - z_n1 * n1_dot_t;\n const x_resTangent2 = x_tangent - x_n2 * n2_dot_t;\n const y_resTangent2 = y_tangent - y_n2 * n2_dot_t;\n const z_resTangent2 = z_tangent - z_n2 * n2_dot_t;\n const magTangent0 = Math.sqrt(x_resTangent0 * x_resTangent0 + y_resTangent0 * y_resTangent0 + z_resTangent0 * z_resTangent0);\n const magTangent1 = Math.sqrt(x_resTangent1 * x_resTangent1 + y_resTangent1 * y_resTangent1 + z_resTangent1 * z_resTangent1);\n const magTangent2 = Math.sqrt(x_resTangent2 * x_resTangent2 + y_resTangent2 * y_resTangent2 + z_resTangent2 * z_resTangent2);\n // Bitangent\n const n0_dot_bt = x_bitangent * x_n0 + y_bitangent * y_n0 + z_bitangent * z_n0;\n const n1_dot_bt = x_bitangent * x_n1 + y_bitangent * y_n1 + z_bitangent * z_n1;\n const n2_dot_bt = x_bitangent * x_n2 + y_bitangent * y_n2 + z_bitangent * z_n2;\n const x_resBitangent0 = x_bitangent - x_n0 * n0_dot_bt;\n const y_resBitangent0 = y_bitangent - y_n0 * n0_dot_bt;\n const z_resBitangent0 = z_bitangent - z_n0 * n0_dot_bt;\n const x_resBitangent1 = x_bitangent - x_n1 * n1_dot_bt;\n const y_resBitangent1 = y_bitangent - y_n1 * n1_dot_bt;\n const z_resBitangent1 = z_bitangent - z_n1 * n1_dot_bt;\n const x_resBitangent2 = x_bitangent - x_n2 * n2_dot_bt;\n const y_resBitangent2 = y_bitangent - y_n2 * n2_dot_bt;\n const z_resBitangent2 = z_bitangent - z_n2 * n2_dot_bt;\n const magBitangent0 = Math.sqrt(x_resBitangent0 * x_resBitangent0 +\n y_resBitangent0 * y_resBitangent0 +\n z_resBitangent0 * z_resBitangent0);\n const magBitangent1 = Math.sqrt(x_resBitangent1 * x_resBitangent1 +\n y_resBitangent1 * y_resBitangent1 +\n z_resBitangent1 * z_resBitangent1);\n const magBitangent2 = Math.sqrt(x_resBitangent2 * x_resBitangent2 +\n y_resBitangent2 * y_resBitangent2 +\n z_resBitangent2 * z_resBitangent2);\n unpacked.tangents[i0 * 3 + 0] += x_resTangent0 / magTangent0;\n unpacked.tangents[i0 * 3 + 1] += y_resTangent0 / magTangent0;\n unpacked.tangents[i0 * 3 + 2] += z_resTangent0 / magTangent0;\n unpacked.tangents[i1 * 3 + 0] += x_resTangent1 / magTangent1;\n unpacked.tangents[i1 * 3 + 1] += y_resTangent1 / magTangent1;\n unpacked.tangents[i1 * 3 + 2] += z_resTangent1 / magTangent1;\n unpacked.tangents[i2 * 3 + 0] += x_resTangent2 / magTangent2;\n unpacked.tangents[i2 * 3 + 1] += y_resTangent2 / magTangent2;\n unpacked.tangents[i2 * 3 + 2] += z_resTangent2 / magTangent2;\n unpacked.bitangents[i0 * 3 + 0] += x_resBitangent0 / magBitangent0;\n unpacked.bitangents[i0 * 3 + 1] += y_resBitangent0 / magBitangent0;\n unpacked.bitangents[i0 * 3 + 2] += z_resBitangent0 / magBitangent0;\n unpacked.bitangents[i1 * 3 + 0] += x_resBitangent1 / magBitangent1;\n unpacked.bitangents[i1 * 3 + 1] += y_resBitangent1 / magBitangent1;\n unpacked.bitangents[i1 * 3 + 2] += z_resBitangent1 / magBitangent1;\n unpacked.bitangents[i2 * 3 + 0] += x_resBitangent2 / magBitangent2;\n unpacked.bitangents[i2 * 3 + 1] += y_resBitangent2 / magBitangent2;\n unpacked.bitangents[i2 * 3 + 2] += z_resBitangent2 / magBitangent2;\n // TODO: check handedness\n }\n this.tangents = unpacked.tangents;\n this.bitangents = unpacked.bitangents;\n }\n /**\n * @param layout - A {@link Layout} object that describes the\n * desired memory layout of the generated buffer\n * @return The packed array in the ... TODO\n */\n makeBufferData(layout) {\n const numItems = this.vertices.length / 3;\n const buffer = new ArrayBuffer(layout.stride * numItems);\n buffer.numItems = numItems;\n const dataView = new DataView(buffer);\n for (let i = 0, vertexOffset = 0; i < numItems; i++) {\n vertexOffset = i * layout.stride;\n // copy in the vertex data in the order and format given by the\n // layout param\n for (const attribute of layout.attributes) {\n const offset = vertexOffset + layout.attributeMap[attribute.key].offset;\n switch (attribute.key) {\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].POSITION.key:\n dataView.setFloat32(offset, this.vertices[i * 3], true);\n dataView.setFloat32(offset + 4, this.vertices[i * 3 + 1], true);\n dataView.setFloat32(offset + 8, this.vertices[i * 3 + 2], true);\n break;\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].UV.key:\n dataView.setFloat32(offset, this.textures[i * 2], true);\n dataView.setFloat32(offset + 4, this.textures[i * 2 + 1], true);\n break;\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].NORMAL.key:\n dataView.setFloat32(offset, this.vertexNormals[i * 3], true);\n dataView.setFloat32(offset + 4, this.vertexNormals[i * 3 + 1], true);\n dataView.setFloat32(offset + 8, this.vertexNormals[i * 3 + 2], true);\n break;\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].MATERIAL_INDEX.key:\n dataView.setInt16(offset, this.vertexMaterialIndices[i], true);\n break;\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].AMBIENT.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.ambient[0], true);\n dataView.setFloat32(offset + 4, material.ambient[1], true);\n dataView.setFloat32(offset + 8, material.ambient[2], true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].DIFFUSE.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.diffuse[0], true);\n dataView.setFloat32(offset + 4, material.diffuse[1], true);\n dataView.setFloat32(offset + 8, material.diffuse[2], true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].SPECULAR.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.specular[0], true);\n dataView.setFloat32(offset + 4, material.specular[1], true);\n dataView.setFloat32(offset + 8, material.specular[2], true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].SPECULAR_EXPONENT.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.specularExponent, true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].EMISSIVE.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.emissive[0], true);\n dataView.setFloat32(offset + 4, material.emissive[1], true);\n dataView.setFloat32(offset + 8, material.emissive[2], true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].TRANSMISSION_FILTER.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.transmissionFilter[0], true);\n dataView.setFloat32(offset + 4, material.transmissionFilter[1], true);\n dataView.setFloat32(offset + 8, material.transmissionFilter[2], true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].DISSOLVE.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.dissolve, true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].ILLUMINATION.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setInt16(offset, material.illumination, true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].REFRACTION_INDEX.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.refractionIndex, true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].SHARPNESS.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setFloat32(offset, material.sharpness, true);\n break;\n }\n case _layout__WEBPACK_IMPORTED_MODULE_0__["Layout"].ANTI_ALIASING.key: {\n const materialIndex = this.vertexMaterialIndices[i];\n const material = this.materialsByIndex[materialIndex];\n if (!material) {\n console.warn(\'Material "\' +\n this.materialNames[materialIndex] +\n \'" not found in mesh. Did you forget to call addMaterialLibrary(...)?"\');\n break;\n }\n dataView.setInt16(offset, material.antiAliasing ? 1 : 0, true);\n break;\n }\n }\n }\n }\n return buffer;\n }\n makeIndexBufferData() {\n const buffer = new Uint16Array(this.indices);\n buffer.numItems = this.indices.length;\n return buffer;\n }\n makeIndexBufferDataForMaterials(...materialIndices) {\n const indices = new Array().concat(...materialIndices.map(mtlIdx => this.indicesPerMaterial[mtlIdx]));\n const buffer = new Uint16Array(indices);\n buffer.numItems = indices.length;\n return buffer;\n }\n addMaterialLibrary(mtl) {\n for (const name in mtl.materials) {\n if (!(name in this.materialIndices)) {\n // This material is not referenced by the mesh\n continue;\n }\n const material = mtl.materials[name];\n // Find the material index for this material\n const materialIndex = this.materialIndices[material.name];\n // Put the material into the materialsByIndex object at the right\n // spot as determined when the obj file was parsed\n this.materialsByIndex[materialIndex] = material;\n }\n }\n}\nfunction* triangulate(elements) {\n if (elements.length <= 3) {\n yield elements;\n }\n else if (elements.length === 4) {\n yield [elements[0], elements[1], elements[2]];\n yield [elements[2], elements[3], elements[0]];\n }\n else {\n for (let i = 1; i < elements.length - 1; i++) {\n yield [elements[0], elements[i], elements[i + 1]];\n }\n }\n}\n\n\n//# sourceURL=webpack:///./src/mesh.ts?');},"./src/utils.ts":
|
|
1881
|
+
/*!**********************!*\
|
|
1882
|
+
!*** ./src/utils.ts ***!
|
|
1883
|
+
\**********************/
|
|
1884
|
+
/*! exports provided: downloadModels, downloadMeshes, initMeshBuffers, deleteMeshBuffers */function(module,__webpack_exports__,__webpack_require__){eval('__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "downloadModels", function() { return downloadModels; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "downloadMeshes", function() { return downloadMeshes; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "initMeshBuffers", function() { return initMeshBuffers; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "deleteMeshBuffers", function() { return deleteMeshBuffers; });\n/* harmony import */ var _mesh__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./mesh */ "./src/mesh.ts");\n/* harmony import */ var _material__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./material */ "./src/material.ts");\n\n\nfunction downloadMtlTextures(mtl, root) {\n const mapAttributes = [\n "mapDiffuse",\n "mapAmbient",\n "mapSpecular",\n "mapDissolve",\n "mapBump",\n "mapDisplacement",\n "mapDecal",\n "mapEmissive",\n ];\n if (!root.endsWith("/")) {\n root += "/";\n }\n const textures = [];\n for (const materialName in mtl.materials) {\n if (!mtl.materials.hasOwnProperty(materialName)) {\n continue;\n }\n const material = mtl.materials[materialName];\n for (const attr of mapAttributes) {\n const mapData = material[attr];\n if (!mapData || !mapData.filename) {\n continue;\n }\n const url = root + mapData.filename;\n textures.push(fetch(url)\n .then(response => {\n if (!response.ok) {\n throw new Error();\n }\n return response.blob();\n })\n .then(function (data) {\n const image = new Image();\n image.src = URL.createObjectURL(data);\n mapData.texture = image;\n return new Promise(resolve => (image.onload = resolve));\n })\n .catch(() => {\n console.error(`Unable to download texture: ${url}`);\n }));\n }\n }\n return Promise.all(textures);\n}\nfunction getMtl(modelOptions) {\n if (!(typeof modelOptions.mtl === "string")) {\n return modelOptions.obj.replace(/\\.obj$/, ".mtl");\n }\n return modelOptions.mtl;\n}\n/**\n * Accepts a list of model request objects and returns a Promise that\n * resolves when all models have been downloaded and parsed.\n *\n * The list of model objects follow this interface:\n * {\n * obj: \'path/to/model.obj\',\n * mtl: true | \'path/to/model.mtl\',\n * downloadMtlTextures: true | false\n * mtlTextureRoot: \'/models/suzanne/maps\'\n * name: \'suzanne\'\n * }\n *\n * The `obj` attribute is required and should be the path to the\n * model\'s .obj file relative to the current repo (absolute URLs are\n * suggested).\n *\n * The `mtl` attribute is optional and can either be a boolean or\n * a path to the model\'s .mtl file relative to the current URL. If\n * the value is `true`, then the path and basename given for the `obj`\n * attribute is used replacing the .obj suffix for .mtl\n * E.g.: {obj: \'models/foo.obj\', mtl: true} would search for \'models/foo.mtl\'\n *\n * The `name` attribute is optional and is a human friendly name to be\n * included with the parsed OBJ and MTL files. If not given, the base .obj\n * filename will be used.\n *\n * The `downloadMtlTextures` attribute is a flag for automatically downloading\n * any images found in the MTL file and attaching them to each Material\n * created from that file. For example, if material.mapDiffuse is set (there\n * was data in the MTL file), then material.mapDiffuse.texture will contain\n * the downloaded image. This option defaults to `true`. By default, the MTL\'s\n * URL will be used to determine the location of the images.\n *\n * The `mtlTextureRoot` attribute is optional and should point to the location\n * on the server that this MTL\'s texture files are located. The default is to\n * use the MTL file\'s location.\n *\n * @returns {Promise} the result of downloading the given list of models. The\n * promise will resolve with an object whose keys are the names of the models\n * and the value is its Mesh object. Each Mesh object will automatically\n * have its addMaterialLibrary() method called to set the given MTL data (if given).\n */\nfunction downloadModels(models) {\n const finished = [];\n for (const model of models) {\n if (!model.obj) {\n throw new Error(\'"obj" attribute of model object not set. The .obj file is required to be set \' +\n "in order to use downloadModels()");\n }\n const options = {\n indicesPerMaterial: !!model.indicesPerMaterial,\n calcTangentsAndBitangents: !!model.calcTangentsAndBitangents,\n };\n // if the name is not provided, dervive it from the given OBJ\n let name = model.name;\n if (!name) {\n const parts = model.obj.split("/");\n name = parts[parts.length - 1].replace(".obj", "");\n }\n const namePromise = Promise.resolve(name);\n const meshPromise = fetch(model.obj)\n .then(response => response.text())\n .then(data => {\n return new _mesh__WEBPACK_IMPORTED_MODULE_0__["default"](data, options);\n });\n let mtlPromise;\n // Download MaterialLibrary file?\n if (model.mtl) {\n const mtl = getMtl(model);\n mtlPromise = fetch(mtl)\n .then(response => response.text())\n .then((data) => {\n const material = new _material__WEBPACK_IMPORTED_MODULE_1__["MaterialLibrary"](data);\n if (model.downloadMtlTextures !== false) {\n let root = model.mtlTextureRoot;\n if (!root) {\n // get the directory of the MTL file as default\n root = mtl.substr(0, mtl.lastIndexOf("/"));\n }\n // downloadMtlTextures returns a Promise that\n // is resolved once all of the images it\n // contains are downloaded. These are then\n // attached to the map data objects\n return Promise.all([Promise.resolve(material), downloadMtlTextures(material, root)]);\n }\n return Promise.all([Promise.resolve(material), undefined]);\n })\n .then((value) => {\n return value[0];\n });\n }\n const parsed = [namePromise, meshPromise, mtlPromise];\n finished.push(Promise.all(parsed));\n }\n return Promise.all(finished).then(ms => {\n // the "finished" promise is a list of name, Mesh instance,\n // and MaterialLibary instance. This unpacks and returns an\n // object mapping name to Mesh (Mesh points to MTL).\n const models = {};\n for (const model of ms) {\n const [name, mesh, mtl] = model;\n mesh.name = name;\n if (mtl) {\n mesh.addMaterialLibrary(mtl);\n }\n models[name] = mesh;\n }\n return models;\n });\n}\n/**\n * Takes in an object of `mesh_name`, `\'/url/to/OBJ/file\'` pairs and a callback\n * function. Each OBJ file will be ajaxed in and automatically converted to\n * an OBJ.Mesh. When all files have successfully downloaded the callback\n * function provided will be called and passed in an object containing\n * the newly created meshes.\n *\n * **Note:** In order to use this function as a way to download meshes, a\n * webserver of some sort must be used.\n *\n * @param {Object} nameAndAttrs an object where the key is the name of the mesh and the value is the url to that mesh\'s OBJ file\n *\n * @param {Function} completionCallback should contain a function that will take one parameter: an object array where the keys will be the unique object name and the value will be a Mesh object\n *\n * @param {Object} meshes In case other meshes are loaded separately or if a previously declared variable is desired to be used, pass in a (possibly empty) json object of the pattern: { \'<mesh_name>\': OBJ.Mesh }\n *\n */\nfunction downloadMeshes(nameAndURLs, completionCallback, meshes) {\n if (meshes === undefined) {\n meshes = {};\n }\n const completed = [];\n for (const mesh_name in nameAndURLs) {\n if (!nameAndURLs.hasOwnProperty(mesh_name)) {\n continue;\n }\n const url = nameAndURLs[mesh_name];\n completed.push(fetch(url)\n .then(response => response.text())\n .then(data => {\n return [mesh_name, new _mesh__WEBPACK_IMPORTED_MODULE_0__["default"](data)];\n }));\n }\n Promise.all(completed).then(ms => {\n for (const [name, mesh] of ms) {\n meshes[name] = mesh;\n }\n return completionCallback(meshes);\n });\n}\nfunction _buildBuffer(gl, type, data, itemSize) {\n const buffer = gl.createBuffer();\n const arrayView = type === gl.ARRAY_BUFFER ? Float32Array : Uint16Array;\n gl.bindBuffer(type, buffer);\n gl.bufferData(type, new arrayView(data), gl.STATIC_DRAW);\n buffer.itemSize = itemSize;\n buffer.numItems = data.length / itemSize;\n return buffer;\n}\n/**\n * Takes in the WebGL context and a Mesh, then creates and appends the buffers\n * to the mesh object as attributes.\n *\n * @param {WebGLRenderingContext} gl the `canvas.getContext(\'webgl\')` context instance\n * @param {Mesh} mesh a single `OBJ.Mesh` instance\n *\n * The newly created mesh attributes are:\n *\n * Attrbute | Description\n * :--- | ---\n * **normalBuffer** |contains the model's Vertex Normals\n * normalBuffer.itemSize |set to 3 items\n * normalBuffer.numItems |the total number of vertex normals\n * |\n * **textureBuffer** |contains the model's Texture Coordinates\n * textureBuffer.itemSize |set to 2 items\n * textureBuffer.numItems |the number of texture coordinates\n * |\n * **vertexBuffer** |contains the model's Vertex Position Coordinates (does not include w)\n * vertexBuffer.itemSize |set to 3 items\n * vertexBuffer.numItems |the total number of vertices\n * |\n * **indexBuffer** |contains the indices of the faces\n * indexBuffer.itemSize |is set to 1\n * indexBuffer.numItems |the total number of indices\n *\n * A simple example (a lot of steps are missing, so don\'t copy and paste):\n *\n * const gl = canvas.getContext(\'webgl\'),\n * mesh = OBJ.Mesh(obj_file_data);\n * // compile the shaders and create a shader program\n * const shaderProgram = gl.createProgram();\n * // compilation stuff here\n * ...\n * // make sure you have vertex, vertex normal, and texture coordinate\n * // attributes located in your shaders and attach them to the shader program\n * shaderProgram.vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "aVertexPosition");\n * gl.enableVertexAttribArray(shaderProgram.vertexPositionAttribute);\n *\n * shaderProgram.vertexNormalAttribute = gl.getAttribLocation(shaderProgram, "aVertexNormal");\n * gl.enableVertexAttribArray(shaderProgram.vertexNormalAttribute);\n *\n * shaderProgram.textureCoordAttribute = gl.getAttribLocation(shaderProgram, "aTextureCoord");\n * gl.enableVertexAttribArray(shaderProgram.textureCoordAttribute);\n *\n * // create and initialize the vertex, vertex normal, and texture coordinate buffers\n * // and save on to the mesh object\n * OBJ.initMeshBuffers(gl, mesh);\n *\n * // now to render the mesh\n * gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vertexBuffer);\n * gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, mesh.vertexBuffer.itemSize, gl.FLOAT, false, 0, 0);\n * // it\'s possible that the mesh doesn\'t contain\n * // any texture coordinates (e.g. suzanne.obj in the development branch).\n * // in this case, the texture vertexAttribArray will need to be disabled\n * // before the call to drawElements\n * if(!mesh.textures.length){\n * gl.disableVertexAttribArray(shaderProgram.textureCoordAttribute);\n * }\n * else{\n * // if the texture vertexAttribArray has been previously\n * // disabled, then it needs to be re-enabled\n * gl.enableVertexAttribArray(shaderProgram.textureCoordAttribute);\n * gl.bindBuffer(gl.ARRAY_BUFFER, mesh.textureBuffer);\n * gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, mesh.textureBuffer.itemSize, gl.FLOAT, false, 0, 0);\n * }\n *\n * gl.bindBuffer(gl.ARRAY_BUFFER, mesh.normalBuffer);\n * gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, mesh.normalBuffer.itemSize, gl.FLOAT, false, 0, 0);\n *\n * gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, model.mesh.indexBuffer);\n * gl.drawElements(gl.TRIANGLES, model.mesh.indexBuffer.numItems, gl.UNSIGNED_SHORT, 0);\n */\nfunction initMeshBuffers(gl, mesh) {\n mesh.normalBuffer = _buildBuffer(gl, gl.ARRAY_BUFFER, mesh.vertexNormals, 3);\n mesh.textureBuffer = _buildBuffer(gl, gl.ARRAY_BUFFER, mesh.textures, mesh.textureStride);\n mesh.vertexBuffer = _buildBuffer(gl, gl.ARRAY_BUFFER, mesh.vertices, 3);\n mesh.indexBuffer = _buildBuffer(gl, gl.ELEMENT_ARRAY_BUFFER, mesh.indices, 1);\n return mesh;\n}\nfunction deleteMeshBuffers(gl, mesh) {\n gl.deleteBuffer(mesh.normalBuffer);\n gl.deleteBuffer(mesh.textureBuffer);\n gl.deleteBuffer(mesh.vertexBuffer);\n gl.deleteBuffer(mesh.indexBuffer);\n}\n\n\n//# sourceURL=webpack:///./src/utils.ts?');},0:
|
|
1885
|
+
/*!****************************!*\
|
|
1886
|
+
!*** multi ./src/index.ts ***!
|
|
1887
|
+
\****************************/
|
|
1888
|
+
/*! no static exports found */function(module,exports,__webpack_require__){eval('module.exports = __webpack_require__(/*! /home/aaron/google_drive/projects/webgl-obj-loader/src/index.ts */"./src/index.ts");\n\n\n//# sourceURL=webpack:///multi_./src/index.ts?');}})}));
|
|
1889
|
+
} (webglObjLoader_min$1));
|
|
1890
|
+
return webglObjLoader_min$1.exports;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
var webglObjLoader_minExports = requireWebglObjLoader_min();
|
|
1894
|
+
var OBJ = /*@__PURE__*/getDefaultExportFromCjs(webglObjLoader_minExports);
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* @file Media sources — images, video and Canvas2D rasters for a bridge's textures, OBJ models for its buffers.
|
|
1898
|
+
* @module host/media
|
|
1899
|
+
* @license AGPL-3.0-only
|
|
1900
|
+
*
|
|
1901
|
+
* A model arrives as arrays for a bridge's buffer upload: loadModel fetches an
|
|
1902
|
+
* OBJ file and adapts webgl-obj-loader's mesh, no parser of its own.
|
|
1903
|
+
*
|
|
1904
|
+
* What a texture upload reads from: an ImageBitmap fetched from a URL, a
|
|
1905
|
+
* hidden <video> element from a file or the user's camera, a bitmap drawn
|
|
1906
|
+
* through a 2D context (the text path — glyph strips, billboard tags,
|
|
1907
|
+
* anything Canvas2D can typeset). No orientation option anywhere: whether
|
|
1908
|
+
* rows flip is the bridge's upload, since it depends on the GPU API's
|
|
1909
|
+
* texture space.
|
|
1910
|
+
*/
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Fetch an image into an ImageBitmap.
|
|
1915
|
+
* @param {string} url
|
|
1916
|
+
* @param {{ fetch?:object, bitmap?:object }} [opts]
|
|
1917
|
+
* fetch: the fetch init (credentials, mode, …). bitmap: the
|
|
1918
|
+
* createImageBitmap options (premultiplyAlpha, colorSpaceConversion, …).
|
|
1919
|
+
* @returns {Promise<ImageBitmap>}
|
|
1920
|
+
*/
|
|
1921
|
+
async function loadImage(url, opts) {
|
|
1922
|
+
const o = opts || {};
|
|
1923
|
+
const res = await fetch(url, o.fetch);
|
|
1924
|
+
if (!res.ok) throw new Error('[host] image: ' + url + ' → ' + res.status);
|
|
1925
|
+
const blob = await res.blob();
|
|
1926
|
+
return o.bitmap ? createImageBitmap(blob, o.bitmap) : createImageBitmap(blob);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* Fetch an OBJ model into the arrays shape: position and indices, plus normal
|
|
1931
|
+
* and texcoord when the file carries them. twgl's createBufferInfoFromArrays
|
|
1932
|
+
* takes the result as it is.
|
|
1933
|
+
*
|
|
1934
|
+
* Parsing is webgl-obj-loader's: faces are triangulated and each distinct
|
|
1935
|
+
* position / uv / normal triple becomes one vertex. Materials are ignored.
|
|
1936
|
+
*
|
|
1937
|
+
* @param {string} url
|
|
1938
|
+
* @param {{ fetch?:object }} [opts] fetch: the fetch init (credentials, mode, …).
|
|
1939
|
+
* @returns {Promise<{ position:{numComponents:number,data:Float32Array},
|
|
1940
|
+
* normal?:{numComponents:number,data:Float32Array},
|
|
1941
|
+
* texcoord?:{numComponents:number,data:Float32Array},
|
|
1942
|
+
* indices:{numComponents:number,data:Uint32Array} }>}
|
|
1943
|
+
*/
|
|
1944
|
+
async function loadModel(url, opts) {
|
|
1945
|
+
const o = opts || {};
|
|
1946
|
+
const res = await fetch(url, o.fetch);
|
|
1947
|
+
if (!res.ok) throw new Error('[host] model: ' + url + ' → ' + res.status);
|
|
1948
|
+
const mesh = new OBJ.Mesh(await res.text());
|
|
1949
|
+
const out = {
|
|
1950
|
+
position: { numComponents: 3, data: new Float32Array(mesh.vertices) },
|
|
1951
|
+
indices: { numComponents: 3, data: new Uint32Array(mesh.indices) },
|
|
1952
|
+
};
|
|
1953
|
+
// a file without normals leaves the loader's normals unset, one per component
|
|
1954
|
+
const n = mesh.vertexNormals;
|
|
1955
|
+
if (n.length && n.every(Number.isFinite)) out.normal = { numComponents: 3, data: new Float32Array(n) };
|
|
1956
|
+
if (mesh.textures.length) out.texcoord = { numComponents: mesh.textureStride, data: new Float32Array(mesh.textures) };
|
|
1957
|
+
return out;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* A hidden, muted, inline <video> element from a file or the user's camera.
|
|
1962
|
+
* `ready` resolves once metadata arrived (width / height known); a camera
|
|
1963
|
+
* denial rejects it. Inside an iframe a camera source needs allow="camera"
|
|
1964
|
+
* on the frame.
|
|
1965
|
+
*
|
|
1966
|
+
* @param {{ src?:string, camera?:boolean|object, loop?:boolean, autoplay?:boolean,
|
|
1967
|
+
* document?:object, media?:object }} opts
|
|
1968
|
+
* src: a media URL. camera: true or getUserMedia video constraints
|
|
1969
|
+
* ({ facingMode, width, height }). loop (default true for src).
|
|
1970
|
+
* autoplay (default true): start() once ready. document / media: the
|
|
1971
|
+
* Document and MediaDevices to use (defaults: the globals) — the seam
|
|
1972
|
+
* a test fills.
|
|
1973
|
+
* @returns {{ el:HTMLVideoElement, ready:Promise<object>, width:number, height:number,
|
|
1974
|
+
* start():Promise<void>, stop():object, dispose():object }}
|
|
1975
|
+
*/
|
|
1976
|
+
function createVideo(opts) {
|
|
1977
|
+
const o = opts || {};
|
|
1978
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
1979
|
+
if (!doc) { console.error('[host] video: no document.'); return null; }
|
|
1980
|
+
const el = doc.createElement('video');
|
|
1981
|
+
el.muted = true;
|
|
1982
|
+
el.playsInline = true;
|
|
1983
|
+
el.setAttribute('playsinline', '');
|
|
1984
|
+
el.setAttribute('muted', '');
|
|
1985
|
+
el.style.display = 'none';
|
|
1986
|
+
let stream = null;
|
|
1987
|
+
|
|
1988
|
+
const v = {
|
|
1989
|
+
/** The <video> element — the bridge's upload source, refreshed each frame. */
|
|
1990
|
+
el,
|
|
1991
|
+
/** Intrinsic size, known once `ready` resolved. */
|
|
1992
|
+
width: 0, height: 0,
|
|
1993
|
+
/** Resolves with this source once metadata arrived; rejects on a camera denial or a load error. */
|
|
1994
|
+
ready: null,
|
|
1995
|
+
/** Play; resolves when playback began. */
|
|
1996
|
+
start() { const p = el.play(); return p && typeof p.catch === 'function' ? p.catch((e) => console.error('[host] video: play() failed: ' + e.message + '.')) : Promise.resolve(); },
|
|
1997
|
+
/** Pause. Chainable. */
|
|
1998
|
+
stop() { el.pause(); return v; },
|
|
1999
|
+
/** Stop, release the camera tracks, detach the element. */
|
|
2000
|
+
dispose() {
|
|
2001
|
+
el.pause();
|
|
2002
|
+
if (stream) { for (const t of stream.getTracks()) t.stop(); stream = null; }
|
|
2003
|
+
el.removeAttribute('src');
|
|
2004
|
+
el.srcObject = null;
|
|
2005
|
+
if (typeof el.remove === 'function') el.remove();
|
|
2006
|
+
return v;
|
|
2007
|
+
},
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
v.ready = new Promise((resolve, reject) => {
|
|
2011
|
+
const unlisten = () => { el.removeEventListener('loadedmetadata', onMeta); el.removeEventListener('error', onErr); };
|
|
2012
|
+
const fail = (msg) => { unlisten(); reject(new Error('[host] video: ' + msg)); };
|
|
2013
|
+
const onMeta = () => {
|
|
2014
|
+
v.width = el.videoWidth || 0; v.height = el.videoHeight || 0;
|
|
2015
|
+
unlisten();
|
|
2016
|
+
if (o.autoplay !== false) v.start();
|
|
2017
|
+
resolve(v);
|
|
2018
|
+
};
|
|
2019
|
+
const onErr = () => fail('failed to load ' + (o.src || 'the source') + '.');
|
|
2020
|
+
el.addEventListener('loadedmetadata', onMeta);
|
|
2021
|
+
el.addEventListener('error', onErr);
|
|
2022
|
+
if (o.camera) {
|
|
2023
|
+
const media = o.media || (typeof navigator !== 'undefined' && navigator.mediaDevices) || null;
|
|
2024
|
+
if (!media || typeof media.getUserMedia !== 'function') { fail('getUserMedia is unavailable here.'); return; }
|
|
2025
|
+
media.getUserMedia({ video: o.camera === true ? true : o.camera, audio: false })
|
|
2026
|
+
.then((s) => { stream = s; el.srcObject = s; })
|
|
2027
|
+
.catch((e) => fail('camera denied: ' + e.message + '.'));
|
|
2028
|
+
} else if (o.src) {
|
|
2029
|
+
el.loop = o.loop !== false;
|
|
2030
|
+
el.src = o.src;
|
|
2031
|
+
} else {
|
|
2032
|
+
fail('needs { src } or { camera }.');
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
v.ready.catch(() => {}); // an unobserved rejection is the caller's to observe, not a crash
|
|
2036
|
+
return v;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Draw through a 2D context and hand the result back as a texture source: an
|
|
2041
|
+
* ImageBitmap where OffscreenCanvas exists, else the canvas element itself.
|
|
2042
|
+
*
|
|
2043
|
+
* @param {function(CanvasRenderingContext2D, number, number):void} draw
|
|
2044
|
+
* @param {number} w Pixel width.
|
|
2045
|
+
* @param {number} h Pixel height.
|
|
2046
|
+
* @param {{ document?:object }} [opts] The Document for the fallback canvas.
|
|
2047
|
+
* @returns {ImageBitmap|HTMLCanvasElement|null}
|
|
2048
|
+
*/
|
|
2049
|
+
function raster(draw, w, h, opts) {
|
|
2050
|
+
const o = opts || {};
|
|
2051
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
2052
|
+
const c = new OffscreenCanvas(w, h);
|
|
2053
|
+
draw(c.getContext('2d'), w, h);
|
|
2054
|
+
return c.transferToImageBitmap();
|
|
2055
|
+
}
|
|
2056
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
2057
|
+
if (!doc) { console.error('[host] raster: no OffscreenCanvas and no document.'); return null; }
|
|
2058
|
+
const c = doc.createElement('canvas');
|
|
2059
|
+
c.width = w; c.height = h;
|
|
2060
|
+
draw(c.getContext('2d'), w, h);
|
|
2061
|
+
return c;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* @file The label layer — DOM text over the canvas, anchored in world or screen space.
|
|
2066
|
+
* @module host/labels
|
|
2067
|
+
* @license AGPL-3.0-only
|
|
2068
|
+
*
|
|
2069
|
+
* The replacement for WEBGL text() in HUDs, gizmo labels and formula
|
|
2070
|
+
* readouts, with the browser's own shaping. One absolutely positioned <div>
|
|
2071
|
+
* overlays the canvas as its next sibling, pointer-events: none, sized by
|
|
2072
|
+
* the canvas observer; the host sets the canvas parent to position:
|
|
2073
|
+
* relative when it is static, and says so in the console once.
|
|
2074
|
+
*
|
|
2075
|
+
* labels.set(id, text, x, y, z, opts) // a world anchor, projected each tick()
|
|
2076
|
+
* labels.setScreen(id, text, sx, sy, opts) // canvas px, the HUD form
|
|
2077
|
+
* labels.remove(id) · labels.clear() · labels.visible
|
|
2078
|
+
* labels.tick() // after the view bag is current
|
|
2079
|
+
*
|
|
2080
|
+
* opts: dx / dy pixel offsets, anchor ('center' default, 'left', 'right'),
|
|
2081
|
+
* class — a CSS class beside the layer's own 'host-label', frame — a
|
|
2082
|
+
* transient label that lives for the frame it was set in: a gizmo re-sets
|
|
2083
|
+
* it every draw and tick() removes it once a frame passes without. A label
|
|
2084
|
+
* whose screen depth leaves [0, 1] or whose anchor leaves the canvas is
|
|
2085
|
+
* hidden, not clamped. Elements are reused by id; tick() writes transforms
|
|
2086
|
+
* only, text when the string changed.
|
|
2087
|
+
*/
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
const _p = [0, 0, 0];
|
|
2091
|
+
const _ANCHOR = { center: -50, left: 0, right: -100 };
|
|
2092
|
+
const _NO_OPTS = {};
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Create the label layer of a canvas. The layer is inserted at once; it
|
|
2096
|
+
* needs a parent, so create it after the canvas is in the document.
|
|
2097
|
+
*
|
|
2098
|
+
* @param {object} host Its canvas, view bag and size are read.
|
|
2099
|
+
* @param {{ document?:object }} [opts] The Document (default: the global).
|
|
2100
|
+
* @returns {object|null} The layer, or null without a document or a parent.
|
|
2101
|
+
*/
|
|
2102
|
+
function createLabels(host, opts) {
|
|
2103
|
+
const o = opts || {};
|
|
2104
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
2105
|
+
const canvas = host.canvas;
|
|
2106
|
+
const parent = canvas && canvas.parentNode;
|
|
2107
|
+
if (!doc || !parent) { console.error('[host] labels: the canvas needs a parent element.'); return null; }
|
|
2108
|
+
|
|
2109
|
+
const el = doc.createElement('div');
|
|
2110
|
+
el.className = 'host-labels';
|
|
2111
|
+
Object.assign(el.style, {
|
|
2112
|
+
position: 'absolute', left: '0px', top: '0px', width: '0px', height: '0px',
|
|
2113
|
+
pointerEvents: 'none', overflow: 'hidden',
|
|
2114
|
+
});
|
|
2115
|
+
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(parent) : null;
|
|
2116
|
+
if (cs && cs.position === 'static') {
|
|
2117
|
+
parent.style.position = 'relative';
|
|
2118
|
+
console.info('[host] labels: set the canvas parent to position: relative for the label layer.');
|
|
2119
|
+
}
|
|
2120
|
+
parent.insertBefore(el, canvas.nextSibling);
|
|
2121
|
+
|
|
2122
|
+
const entries = new Map(); // id → { el, text, x, y, z, dx, dy, ax, screen, cls, sx, sy, shown }
|
|
2123
|
+
let width = 0, height = 0, left = -1, top = -1, visible = true;
|
|
2124
|
+
|
|
2125
|
+
const entry = (id, text, o) => {
|
|
2126
|
+
let e = entries.get(id);
|
|
2127
|
+
if (!e) {
|
|
2128
|
+
const span = doc.createElement('span');
|
|
2129
|
+
Object.assign(span.style, { position: 'absolute', left: '0px', top: '0px', whiteSpace: 'nowrap', willChange: 'transform' });
|
|
2130
|
+
span.style.visibility = 'hidden';
|
|
2131
|
+
el.appendChild(span);
|
|
2132
|
+
e = { el: span, text: null, x: 0, y: 0, z: 0, dx: 0, dy: 0, ax: -50, screen: false, cls: null, sx: NaN, sy: NaN, shown: false, frame: false, fresh: false };
|
|
2133
|
+
entries.set(id, e);
|
|
2134
|
+
}
|
|
2135
|
+
if (e.text !== text) { e.text = text; e.el.textContent = text; }
|
|
2136
|
+
const c = o.class || null;
|
|
2137
|
+
if (e.cls !== c) { e.cls = c; e.el.className = c ? 'host-label ' + c : 'host-label'; }
|
|
2138
|
+
e.ax = _ANCHOR[o.anchor] ?? -50;
|
|
2139
|
+
e.dx = o.dx || 0; e.dy = o.dy || 0;
|
|
2140
|
+
e.frame = !!o.frame; e.fresh = true;
|
|
2141
|
+
return e;
|
|
2142
|
+
};
|
|
2143
|
+
const place = (e, sx, sy) => {
|
|
2144
|
+
if (sx === e.sx && sy === e.sy) return;
|
|
2145
|
+
e.sx = sx; e.sy = sy;
|
|
2146
|
+
e.el.style.transform = 'translate(' + sx + 'px, ' + sy + 'px) translate(' + e.ax + '%, -50%)';
|
|
2147
|
+
};
|
|
2148
|
+
const show = (e, on) => {
|
|
2149
|
+
if (e.shown === on) return;
|
|
2150
|
+
e.shown = on;
|
|
2151
|
+
e.el.style.visibility = on ? '' : 'hidden';
|
|
2152
|
+
};
|
|
2153
|
+
|
|
2154
|
+
const labels = {
|
|
2155
|
+
/** The layer element. */
|
|
2156
|
+
el,
|
|
2157
|
+
/** Whether the layer shows; false hides every label at once. */
|
|
2158
|
+
get visible() { return visible; },
|
|
2159
|
+
set visible(v) { visible = !!v; el.style.display = visible ? '' : 'none'; },
|
|
2160
|
+
/** Live label count. */
|
|
2161
|
+
get size() { return entries.size; },
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* Place a label at a world anchor, projected each tick().
|
|
2165
|
+
* @param {string} id
|
|
2166
|
+
* @param {string} text
|
|
2167
|
+
* @param {number} x, y, z World anchor.
|
|
2168
|
+
* @param {{ dx?:number, dy?:number, anchor?:string, class?:string, frame?:boolean }} [opts]
|
|
2169
|
+
* @returns {object} this
|
|
2170
|
+
*/
|
|
2171
|
+
set(id, text, x, y, z, opts) {
|
|
2172
|
+
const e = entry(id, text, opts || _NO_OPTS);
|
|
2173
|
+
e.screen = false; e.x = x; e.y = y; e.z = z;
|
|
2174
|
+
return labels;
|
|
2175
|
+
},
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Place a label in canvas px — the HUD form.
|
|
2179
|
+
* @param {string} id
|
|
2180
|
+
* @param {string} text
|
|
2181
|
+
* @param {number} sx, sy Canvas px, y down.
|
|
2182
|
+
* @param {{ dx?:number, dy?:number, anchor?:string, class?:string, frame?:boolean }} [opts]
|
|
2183
|
+
* @returns {object} this
|
|
2184
|
+
*/
|
|
2185
|
+
setScreen(id, text, sx, sy, opts) {
|
|
2186
|
+
const e = entry(id, text, opts || _NO_OPTS);
|
|
2187
|
+
e.screen = true; e.x = sx; e.y = sy; e.z = 0;
|
|
2188
|
+
return labels;
|
|
2189
|
+
},
|
|
2190
|
+
|
|
2191
|
+
/** Remove a label. */
|
|
2192
|
+
remove(id) {
|
|
2193
|
+
const e = entries.get(id);
|
|
2194
|
+
if (e) { el.removeChild(e.el); entries.delete(id); }
|
|
2195
|
+
return labels;
|
|
2196
|
+
},
|
|
2197
|
+
|
|
2198
|
+
/** Remove every label. */
|
|
2199
|
+
clear() { for (const id of [...entries.keys()]) labels.remove(id); return labels; },
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Project every world label through the view bag and write the
|
|
2203
|
+
* transforms; hide what falls outside the canvas or the depth range.
|
|
2204
|
+
* Call after the view bag is current for the frame.
|
|
2205
|
+
* @returns {object} this
|
|
2206
|
+
*/
|
|
2207
|
+
tick() {
|
|
2208
|
+
const ol = canvas.offsetLeft || 0, ot = canvas.offsetTop || 0;
|
|
2209
|
+
if (ol !== left || ot !== top) { left = ol; top = ot; el.style.left = ol + 'px'; el.style.top = ot + 'px'; }
|
|
2210
|
+
const view = host.view;
|
|
2211
|
+
for (const [id, e] of entries) {
|
|
2212
|
+
if (e.frame && !e.fresh) { labels.remove(id); continue; }
|
|
2213
|
+
e.fresh = false;
|
|
2214
|
+
let sx, sy, on;
|
|
2215
|
+
if (e.screen) { sx = e.x; sy = e.y; on = true; }
|
|
2216
|
+
else if (!view || view.stale) { on = false; }
|
|
2217
|
+
else {
|
|
2218
|
+
mapLocation(_p, e.x, e.y, e.z, WORLD, SCREEN, view, view.vp, view.ndcZMin);
|
|
2219
|
+
sx = _p[0]; sy = _p[1];
|
|
2220
|
+
on = _p[2] >= 0 && _p[2] <= 1 && sx >= 0 && sx <= width && sy >= 0 && sy <= height;
|
|
2221
|
+
}
|
|
2222
|
+
if (on) place(e, sx + e.dx, sy + e.dy);
|
|
2223
|
+
show(e, on);
|
|
2224
|
+
}
|
|
2225
|
+
return labels;
|
|
2226
|
+
},
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* The observer's write: the layer's size in CSS px.
|
|
2230
|
+
* @param {number} w
|
|
2231
|
+
* @param {number} h
|
|
2232
|
+
* @returns {object} this
|
|
2233
|
+
*/
|
|
2234
|
+
resize(w, h) {
|
|
2235
|
+
width = w; height = h;
|
|
2236
|
+
el.style.width = w + 'px'; el.style.height = h + 'px';
|
|
2237
|
+
return labels;
|
|
2238
|
+
},
|
|
2239
|
+
|
|
2240
|
+
/** Remove the layer and its labels. */
|
|
2241
|
+
dispose() {
|
|
2242
|
+
entries.clear();
|
|
2243
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
2244
|
+
host.unregister(labels);
|
|
2245
|
+
return labels;
|
|
2246
|
+
},
|
|
2247
|
+
};
|
|
2248
|
+
labels.resize(host.width || 0, host.height || 0);
|
|
2249
|
+
return host.register(labels);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* @file The orbit — the fall-through camera gesture on unclaimed pointers.
|
|
2254
|
+
* @module host/orbit
|
|
2255
|
+
* @license AGPL-3.0-only
|
|
2256
|
+
*
|
|
2257
|
+
* What the handle gate yields to: one pointer orbits the camera state about
|
|
2258
|
+
* its center, two pointers dolly by their distance and pan by their
|
|
2259
|
+
* midpoint, the wheel dollies. update() consumes only unclaimed pointers and
|
|
2260
|
+
* returns true when it moved the camera, so
|
|
2261
|
+
*
|
|
2262
|
+
* if (!h.update()) orbit.update()
|
|
2263
|
+
*
|
|
2264
|
+
* reads as the gate. It writes cam only; the next setCamera(cam) installs it.
|
|
2265
|
+
* Two-pointer motion is computed from the pointers' own coordinates, never
|
|
2266
|
+
* from a browser's pinch synthesis, so it reads the same on every platform;
|
|
2267
|
+
* the canvas gets touch-action: none while the orbit lives so the browser
|
|
2268
|
+
* does not scroll or zoom the page instead. No damping, no inertia: direct
|
|
2269
|
+
* manipulation wants exactness, and a sketch that wants smoothing lerps the
|
|
2270
|
+
* camera it owns.
|
|
2271
|
+
*
|
|
2272
|
+
* Screen y runs down; which world direction that is depends on the
|
|
2273
|
+
* projection installed in the view bag: a y-up projection (GL's, tree's
|
|
2274
|
+
* default) maps a downward drag to the eye's −up, a y-flipped one (p5's,
|
|
2275
|
+
* mat4Proj[5] < 0) to +up. The orbit reads that sign off the bag each
|
|
2276
|
+
* update, so a drag down always brings the scene down.
|
|
2277
|
+
*/
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
const _clamp = { min: 0, max: Infinity };
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Create an orbit on a camera state.
|
|
2284
|
+
*
|
|
2285
|
+
* @param {object} host
|
|
2286
|
+
* @param {object} cam The camera state written.
|
|
2287
|
+
* @param {{ rotate?:number, pan?:number, zoom?:number, wheel?:number,
|
|
2288
|
+
* minDistance?:number, maxDistance?:number, enabled?:boolean }} [opts]
|
|
2289
|
+
* rotate: radians per pixel (default 0.005). pan / zoom / wheel:
|
|
2290
|
+
* scalars on the two-pointer pan, the pinch and the wheel dolly
|
|
2291
|
+
* (default 1). minDistance / maxDistance: gaze-distance clamps.
|
|
2292
|
+
* @returns {object} The orbit: { cam, enabled, rotate, pan, zoom, wheel,
|
|
2293
|
+
* minDistance, maxDistance, update(), home(), dispose() }.
|
|
2294
|
+
*/
|
|
2295
|
+
function createOrbit(host, cam, opts) {
|
|
2296
|
+
const o = opts || {};
|
|
2297
|
+
const canvas = host.canvas;
|
|
2298
|
+
const home = cameraCopy(createCamera(), cam);
|
|
2299
|
+
const tracked = new Map(); // pointer id → { x, y } as last consumed
|
|
2300
|
+
let wheelAcc = 0;
|
|
2301
|
+
|
|
2302
|
+
const onWheel = (e) => {
|
|
2303
|
+
if (typeof e.preventDefault === 'function') e.preventDefault();
|
|
2304
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? (host.height || 1) : 1;
|
|
2305
|
+
wheelAcc += (e.deltaY || 0) * unit;
|
|
2306
|
+
};
|
|
2307
|
+
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
2308
|
+
const touchAction = canvas.style ? canvas.style.touchAction : undefined;
|
|
2309
|
+
if (canvas.style) canvas.style.touchAction = 'none';
|
|
2310
|
+
|
|
2311
|
+
const track = (p) => {
|
|
2312
|
+
let t = tracked.get(p.id);
|
|
2313
|
+
if (!t) { t = { x: p.x, y: p.y }; tracked.set(p.id, t); }
|
|
2314
|
+
else { t.x = p.x; t.y = p.y; }
|
|
2315
|
+
};
|
|
2316
|
+
const distance = () => {
|
|
2317
|
+
const e = cam.eye, c = cam.center;
|
|
2318
|
+
return Math.hypot(e[0] - c[0], e[1] - c[1], e[2] - c[2]);
|
|
2319
|
+
};
|
|
2320
|
+
const dolly = (factor) => {
|
|
2321
|
+
_clamp.min = orbit.minDistance; _clamp.max = orbit.maxDistance;
|
|
2322
|
+
cameraDolly(cam, factor, _clamp);
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
const orbit = {
|
|
2326
|
+
/** The camera state written. */
|
|
2327
|
+
cam,
|
|
2328
|
+
/** False makes update() a no-op that drops its pointer memory. */
|
|
2329
|
+
enabled: o.enabled !== false,
|
|
2330
|
+
/** Radians per pixel of one-pointer drag. */
|
|
2331
|
+
rotate: o.rotate ?? 0.005,
|
|
2332
|
+
/** Scalar on the two-pointer pan. */
|
|
2333
|
+
pan: o.pan ?? 1,
|
|
2334
|
+
/** Scalar on the pinch dolly's exponent. */
|
|
2335
|
+
zoom: o.zoom ?? 1,
|
|
2336
|
+
/** Scalar on the wheel dolly. */
|
|
2337
|
+
wheel: o.wheel ?? 1,
|
|
2338
|
+
/** Gaze-distance clamps for every dolly. */
|
|
2339
|
+
minDistance: o.minDistance ?? 0,
|
|
2340
|
+
maxDistance: o.maxDistance ?? Infinity,
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Consume this frame's unclaimed pointer motion and wheel into cam.
|
|
2344
|
+
* @returns {boolean} Whether the camera moved.
|
|
2345
|
+
*/
|
|
2346
|
+
update() {
|
|
2347
|
+
if (!orbit.enabled) { tracked.clear(); wheelAcc = 0; return false; }
|
|
2348
|
+
const src = host.pointer;
|
|
2349
|
+
let a = null, b = null;
|
|
2350
|
+
for (const p of src.pointers.values()) {
|
|
2351
|
+
// a hovering mouse has an entry without a press; only pressed, unclaimed, live pointers drive
|
|
2352
|
+
if (!p.down || p.owner !== null || p.up || p.cancel) { tracked.delete(p.id); continue; }
|
|
2353
|
+
if (a === null || p.id < a.id) { b = a; a = p; }
|
|
2354
|
+
else if (b === null || p.id < b.id) b = p;
|
|
2355
|
+
}
|
|
2356
|
+
for (const id of tracked.keys()) {
|
|
2357
|
+
if ((a === null || id !== a.id) && (b === null || id !== b.id)) tracked.delete(id);
|
|
2358
|
+
}
|
|
2359
|
+
let moved = false;
|
|
2360
|
+
const view = host.view;
|
|
2361
|
+
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
|
|
2362
|
+
if (a !== null && b === null) {
|
|
2363
|
+
const t = tracked.get(a.id);
|
|
2364
|
+
if (t) {
|
|
2365
|
+
const dx = a.x - t.x, dy = a.y - t.y;
|
|
2366
|
+
if (dx !== 0 || dy !== 0) { cameraOrbit(cam, -dx * orbit.rotate, ys * dy * orbit.rotate); moved = true; }
|
|
2367
|
+
}
|
|
2368
|
+
track(a);
|
|
2369
|
+
} else if (a !== null && b !== null) {
|
|
2370
|
+
const ta = tracked.get(a.id), tb = tracked.get(b.id);
|
|
2371
|
+
if (ta && tb) {
|
|
2372
|
+
const dmx = (a.x + b.x - ta.x - tb.x) / 2, dmy = (a.y + b.y - ta.y - tb.y) / 2;
|
|
2373
|
+
if (dmx !== 0 || dmy !== 0) {
|
|
2374
|
+
const ratio = pixelRatio(view.mat4Proj, -view.vp[3] || 1, -distance(), view.ndcZMin);
|
|
2375
|
+
cameraPan(cam, -dmx * ratio * orbit.pan, ys * dmy * ratio * orbit.pan);
|
|
2376
|
+
moved = true;
|
|
2377
|
+
}
|
|
2378
|
+
const d0 = Math.hypot(ta.x - tb.x, ta.y - tb.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);
|
|
2379
|
+
if (d0 > 0 && d1 > 0 && d0 !== d1) { dolly(Math.pow(d0 / d1, orbit.zoom)); moved = true; }
|
|
2380
|
+
}
|
|
2381
|
+
track(a); track(b);
|
|
2382
|
+
}
|
|
2383
|
+
if (wheelAcc !== 0) {
|
|
2384
|
+
dolly(Math.exp(wheelAcc * 0.001 * orbit.wheel));
|
|
2385
|
+
wheelAcc = 0;
|
|
2386
|
+
moved = true;
|
|
2387
|
+
}
|
|
2388
|
+
return moved;
|
|
2389
|
+
},
|
|
2390
|
+
|
|
2391
|
+
/** Restore the pose captured at creation. */
|
|
2392
|
+
home() { cameraCopy(cam, home); return orbit; },
|
|
2393
|
+
|
|
2394
|
+
/** Remove the wheel listener, restore touch-action, unregister from the host. */
|
|
2395
|
+
dispose() {
|
|
2396
|
+
canvas.removeEventListener('wheel', onWheel);
|
|
2397
|
+
if (canvas.style) canvas.style.touchAction = touchAction;
|
|
2398
|
+
tracked.clear();
|
|
2399
|
+
host.unregister(orbit);
|
|
2400
|
+
return orbit;
|
|
2401
|
+
},
|
|
2402
|
+
};
|
|
2403
|
+
return host.register(orbit);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
4693
2406
|
/**
|
|
4694
2407
|
* @file The host context — one per canvas: pointer, view bag, players, loop, observer.
|
|
4695
2408
|
* @module host
|
|
@@ -4706,6 +2419,10 @@ class TrackHandles {
|
|
|
4706
2419
|
* host.handle(opts) · host.router(handles, opts)
|
|
4707
2420
|
* host.cameraHelm(cam, opts) · host.poseHelm(opts)
|
|
4708
2421
|
* host.poseTrack(opts) · host.cameraTrack(cam, opts)
|
|
2422
|
+
* host.hid(opts) · host.gamepad(opts)
|
|
2423
|
+
* host.image(url) · host.video(opts) · host.raster(draw, w, h) · host.model(url)
|
|
2424
|
+
* host.labels // the DOM label layer, created on first access
|
|
2425
|
+
* host.orbit(cam, opts)
|
|
4709
2426
|
* host.tick(dt) // external-loop mode
|
|
4710
2427
|
* host.dispose()
|
|
4711
2428
|
*
|
|
@@ -4740,7 +2457,7 @@ function createHost(canvas, opts) {
|
|
|
4740
2457
|
const o = opts || {};
|
|
4741
2458
|
const t0 = _now();
|
|
4742
2459
|
const constructs = new Set();
|
|
4743
|
-
let loop = null, observer = null;
|
|
2460
|
+
let loop = null, observer = null, labels = null;
|
|
4744
2461
|
const host = {
|
|
4745
2462
|
canvas,
|
|
4746
2463
|
width: 0, height: 0, dpr: 1,
|
|
@@ -4788,6 +2505,30 @@ function createHost(canvas, opts) {
|
|
|
4788
2505
|
poseTrack(opts) { return poseTrack(host, opts); },
|
|
4789
2506
|
/** A core CameraTrack evaluating into a camera state while it plays — see host/track. */
|
|
4790
2507
|
cameraTrack(cam, opts) { return cameraTrack(host, cam, opts); },
|
|
2508
|
+
/** A WebHID 6-DOF rate stream — see host/stream. */
|
|
2509
|
+
hid(opts) { return createHid(host, opts); },
|
|
2510
|
+
/** A Gamepad rate stream, polled each tick — see host/stream. */
|
|
2511
|
+
gamepad(opts) { return createGamepad(host, opts); },
|
|
2512
|
+
/** Fetch an image into an ImageBitmap — see host/media. */
|
|
2513
|
+
image(url, opts) { return loadImage(url, opts); },
|
|
2514
|
+
/** A hidden <video> from a file or the camera, disposed with the host — see host/media. */
|
|
2515
|
+
video(opts) { return host.register(createVideo(opts)); },
|
|
2516
|
+
/** Draw through a 2D context into a texture source — see host/media. */
|
|
2517
|
+
raster(draw, w, h, opts) { return raster(draw, w, h, opts); },
|
|
2518
|
+
/** Fetch an OBJ model into the arrays shape — see host/media. */
|
|
2519
|
+
model(url, opts) { return loadModel(url, opts); },
|
|
2520
|
+
/**
|
|
2521
|
+
* The label layer — see host/labels. Created on first access, so a
|
|
2522
|
+
* canvas that never labels never gets a layer nor a repositioned parent.
|
|
2523
|
+
*/
|
|
2524
|
+
get labels() {
|
|
2525
|
+
if (labels === null) labels = createLabels(host);
|
|
2526
|
+
return labels;
|
|
2527
|
+
},
|
|
2528
|
+
/** Whether the label layer exists, without creating it. */
|
|
2529
|
+
get hasLabels() { return labels !== null; },
|
|
2530
|
+
/** The fall-through camera gesture on unclaimed pointers — see host/orbit. */
|
|
2531
|
+
orbit(cam, opts) { return createOrbit(host, cam, opts); },
|
|
4791
2532
|
|
|
4792
2533
|
/** Register a construct with dispose() so host.dispose() releases it. */
|
|
4793
2534
|
register(c) { if (c) constructs.add(c); return c; },
|
|
@@ -4799,6 +2540,7 @@ function createHost(canvas, opts) {
|
|
|
4799
2540
|
if (loop) loop.stop();
|
|
4800
2541
|
for (const c of [...constructs]) { if (typeof c.dispose === 'function') c.dispose(); }
|
|
4801
2542
|
constructs.clear();
|
|
2543
|
+
labels = null;
|
|
4802
2544
|
observer.dispose();
|
|
4803
2545
|
host.pointer.dispose();
|
|
4804
2546
|
host.players.clear();
|
|
@@ -4811,6 +2553,7 @@ function createHost(canvas, opts) {
|
|
|
4811
2553
|
observer = observeCanvas(canvas, (w, h, dpr) => {
|
|
4812
2554
|
host.width = w; host.height = h; host.dpr = dpr;
|
|
4813
2555
|
host.view.resize(w, h);
|
|
2556
|
+
if (labels) labels.resize(w, h);
|
|
4814
2557
|
if (typeof o.onSize === 'function') o.onSize(w, h, dpr);
|
|
4815
2558
|
});
|
|
4816
2559
|
|
|
@@ -4829,5 +2572,5 @@ function createHost(canvas, opts) {
|
|
|
4829
2572
|
return host;
|
|
4830
2573
|
}
|
|
4831
2574
|
|
|
4832
|
-
export { Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createHost, createLoop, createPlayers, createPointer, createView, helmBasis, isConstraint, measureCanvas, observeCanvas, poseHelm, poseTrack, validConstraint };
|
|
2575
|
+
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 };
|
|
4833
2576
|
//# sourceMappingURL=index.js.map
|