@nakednous/host 0.0.2 → 0.0.3
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 +109 -10
- package/dist/index.js +952 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2043,7 +2043,7 @@ const DIAL_EDGE = 0.08;
|
|
|
2043
2043
|
// =========================================================================
|
|
2044
2044
|
|
|
2045
2045
|
const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
2046
|
-
const _clamp = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
|
|
2046
|
+
const _clamp$1 = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
|
|
2047
2047
|
const _num = (x, d) => _isNum(x) ? x : d;
|
|
2048
2048
|
|
|
2049
2049
|
/** Wrap an angle to (−π, π]. */
|
|
@@ -2312,7 +2312,7 @@ function dirFromAzEl(out, az, el) {
|
|
|
2312
2312
|
*/
|
|
2313
2313
|
function azElFromDir(out2, dx, dy, dz) {
|
|
2314
2314
|
out2[0] = Math.atan2(dz, dx); // az
|
|
2315
|
-
out2[1] = Math.asin(_clamp(dy, -1, 1)); // el
|
|
2315
|
+
out2[1] = Math.asin(_clamp$1(dy, -1, 1)); // el
|
|
2316
2316
|
return out2;
|
|
2317
2317
|
}
|
|
2318
2318
|
|
|
@@ -2455,7 +2455,7 @@ class Constraint {
|
|
|
2455
2455
|
let s = rayClosestPointOnAxis(this.pt, ox,oy,oz, dx,dy,dz,
|
|
2456
2456
|
this.anchor[0], this.anchor[1], this.anchor[2],
|
|
2457
2457
|
this.u[0], this.u[1], this.u[2]);
|
|
2458
|
-
s = _clamp(s, this.min, this.max);
|
|
2458
|
+
s = _clamp$1(s, this.min, this.max);
|
|
2459
2459
|
this.s = s;
|
|
2460
2460
|
this.pt[0] = this.anchor[0] + s*this.u[0]; // clamped point
|
|
2461
2461
|
this.pt[1] = this.anchor[1] + s*this.u[1];
|
|
@@ -2496,7 +2496,7 @@ class Constraint {
|
|
|
2496
2496
|
wx, wy, wz);
|
|
2497
2497
|
dth = sl / (this._radius || 1);
|
|
2498
2498
|
}
|
|
2499
|
-
this.s = _clamp(this.s + dth, this.min, this.max);
|
|
2499
|
+
this.s = _clamp$1(this.s + dth, this.min, this.max);
|
|
2500
2500
|
this._dialPoint();
|
|
2501
2501
|
}
|
|
2502
2502
|
|
|
@@ -2580,7 +2580,7 @@ class Constraint {
|
|
|
2580
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
2581
|
} else if (this.kind === AXIS) {
|
|
2582
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);
|
|
2583
|
+
const s = _clamp$1(wx*this.u[0] + wy*this.u[1] + wz*this.u[2], this.min, this.max);
|
|
2584
2584
|
this.s = s;
|
|
2585
2585
|
this.pt[0]=this.anchor[0]+s*this.u[0];
|
|
2586
2586
|
this.pt[1]=this.anchor[1]+s*this.u[1];
|
|
@@ -2592,7 +2592,7 @@ class Constraint {
|
|
|
2592
2592
|
if (px*px + py*py >= EPS*EPS) {
|
|
2593
2593
|
const a = Math.atan2(py, px);
|
|
2594
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),
|
|
2595
|
+
this.s = _clamp$1(a + TWO_PI$1 * Math.round((this.s - a) / TWO_PI$1),
|
|
2596
2596
|
this.min, this.max);
|
|
2597
2597
|
}
|
|
2598
2598
|
this._dialPoint();
|
|
@@ -3002,8 +3002,53 @@ class PoseHelm {
|
|
|
3002
3002
|
return this;
|
|
3003
3003
|
}
|
|
3004
3004
|
}
|
|
3005
|
+
|
|
3006
|
+
/**
|
|
3007
|
+
* @file Camera state ↔ matrices: builders, decomposers, and in-place edits.
|
|
3008
|
+
* @module tree/camera
|
|
3009
|
+
* @license AGPL-3.0-only
|
|
3010
|
+
*
|
|
3011
|
+
* The camera is plain data — the CameraTrack keyframe shape:
|
|
3012
|
+
*
|
|
3013
|
+
* cam = {
|
|
3014
|
+
* eye: [x, y, z], // position
|
|
3015
|
+
* center: [x, y, z], // lookat target; |center − eye| is the gaze distance
|
|
3016
|
+
* up: [x, y, z], // up hint, need not be unit
|
|
3017
|
+
* fov: number | null, // vertical field of view, radians — perspective
|
|
3018
|
+
* halfHeight: number | null, // world-unit half-height at the near plane — orthographic
|
|
3019
|
+
* near: number, // > 0
|
|
3020
|
+
* far: number, // > near
|
|
3021
|
+
* }
|
|
3022
|
+
*
|
|
3023
|
+
* Exactly one of fov / halfHeight is meaningful; fov wins when both are set.
|
|
3024
|
+
* With both null the lens is "unchanged": cameraProj and cameraPlanes return
|
|
3025
|
+
* null and leave their output untouched, so a track that evaluates null into
|
|
3026
|
+
* the state keeps the last projection installed. A track writes the state
|
|
3027
|
+
* directly — `track.eval(cam)` — and a pose drives it through cameraFromPose.
|
|
3028
|
+
*
|
|
3029
|
+
* The state carries no aspect ratio — the viewport owns it — so one state is
|
|
3030
|
+
* portable between targets of different sizes; every builder that needs it
|
|
3031
|
+
* takes it as an argument.
|
|
3032
|
+
*
|
|
3033
|
+
* Vectors are plain number[] (f64, authoring state); matrices are written into
|
|
3034
|
+
* whatever 16-element buffer the caller passes. createCamera is the one
|
|
3035
|
+
* allocating call; everything else is out-first (or in-place) and zero-alloc.
|
|
3036
|
+
*
|
|
3037
|
+
* Every function that needs the camera frame derives it through mat4Eye, so
|
|
3038
|
+
* planes and edits agree with cameraEye / cameraView exactly — including the
|
|
3039
|
+
* up re-seed when the view direction is parallel to the up hint.
|
|
3040
|
+
*/
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
const _E$1 = new Float64Array(16); // eye→world frame scratch: right 0–2, up 4–6, back 8–10
|
|
3005
3044
|
const _d = [0, 0, 0]; // view-direction scratch
|
|
3006
3045
|
|
|
3046
|
+
/** Eye→world frame of `cam` into `_E`. */
|
|
3047
|
+
function _frame(cam) {
|
|
3048
|
+
const e = cam.eye, c = cam.center, u = cam.up;
|
|
3049
|
+
mat4Eye(_E$1, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3007
3052
|
/** Gaze distance |center − eye|, or 1 when the state is degenerate. */
|
|
3008
3053
|
function _gaze(cam) {
|
|
3009
3054
|
const dx = cam.center[0]-cam.eye[0], dy = cam.center[1]-cam.eye[1], dz = cam.center[2]-cam.eye[2];
|
|
@@ -3011,6 +3056,52 @@ function _gaze(cam) {
|
|
|
3011
3056
|
return d > 0 ? d : 1;
|
|
3012
3057
|
}
|
|
3013
3058
|
|
|
3059
|
+
function _vec3(v, x, y, z) { return v != null ? [v[0], v[1], v[2]] : [x, y, z]; }
|
|
3060
|
+
|
|
3061
|
+
// =========================================================================
|
|
3062
|
+
// State
|
|
3063
|
+
// =========================================================================
|
|
3064
|
+
|
|
3065
|
+
/**
|
|
3066
|
+
* Allocate a camera state — the one allocating call, setup-time.
|
|
3067
|
+
* Defaults: eye [0, 0, 500], center [0, 0, 0], up [0, 1, 0], fov π/3,
|
|
3068
|
+
* halfHeight null, near 0.1, far 1000. Passing halfHeight without fov yields
|
|
3069
|
+
* an orthographic state (fov null).
|
|
3070
|
+
*
|
|
3071
|
+
* @param {{ eye?:number[], center?:number[], up?:number[], fov?:number|null,
|
|
3072
|
+
* halfHeight?:number|null, near?:number, far?:number }} [opts]
|
|
3073
|
+
* @returns {{ eye:number[], center:number[], up:number[], fov:number|null,
|
|
3074
|
+
* halfHeight:number|null, near:number, far:number }}
|
|
3075
|
+
*/
|
|
3076
|
+
function createCamera(opts) {
|
|
3077
|
+
const o = {};
|
|
3078
|
+
const ortho = o.halfHeight != null && o.fov === undefined;
|
|
3079
|
+
return {
|
|
3080
|
+
eye: _vec3(o.eye, 0, 0, 500),
|
|
3081
|
+
center: _vec3(o.center, 0, 0, 0),
|
|
3082
|
+
up: _vec3(o.up, 0, 1, 0),
|
|
3083
|
+
fov: o.fov !== undefined ? o.fov : (ortho ? null : Math.PI / 3),
|
|
3084
|
+
halfHeight: o.halfHeight !== undefined ? o.halfHeight : null,
|
|
3085
|
+
near: typeof o.near === 'number' ? o.near : 0.1,
|
|
3086
|
+
far: typeof o.far === 'number' ? o.far : 1000,
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* Copy one camera state into another (an orbit's home, a track's capture).
|
|
3092
|
+
* @param {object} out Destination state.
|
|
3093
|
+
* @param {object} cam Source state.
|
|
3094
|
+
* @returns {object} out
|
|
3095
|
+
*/
|
|
3096
|
+
function cameraCopy(out, cam) {
|
|
3097
|
+
out.eye[0]=cam.eye[0]; out.eye[1]=cam.eye[1]; out.eye[2]=cam.eye[2];
|
|
3098
|
+
out.center[0]=cam.center[0]; out.center[1]=cam.center[1]; out.center[2]=cam.center[2];
|
|
3099
|
+
out.up[0]=cam.up[0]; out.up[1]=cam.up[1]; out.up[2]=cam.up[2];
|
|
3100
|
+
out.fov = cam.fov; out.halfHeight = cam.halfHeight;
|
|
3101
|
+
out.near = cam.near; out.far = cam.far;
|
|
3102
|
+
return out;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3014
3105
|
// =========================================================================
|
|
3015
3106
|
// Builders — state → matrices
|
|
3016
3107
|
// =========================================================================
|
|
@@ -3100,6 +3191,99 @@ function cameraToPose(pose, cam) {
|
|
|
3100
3191
|
return pose;
|
|
3101
3192
|
}
|
|
3102
3193
|
|
|
3194
|
+
// =========================================================================
|
|
3195
|
+
// Edits — in place, chainable, zero-alloc
|
|
3196
|
+
// =========================================================================
|
|
3197
|
+
|
|
3198
|
+
/**
|
|
3199
|
+
* Orbit the eye about the center: `dAz` rotates it about the up hint
|
|
3200
|
+
* (right-handed); `dEl` raises its elevation above the plane through the
|
|
3201
|
+
* center perpendicular to the hint, clamped to ±opts.maxEl so the view
|
|
3202
|
+
* direction never reaches the hint (the pole guard). `up` is left as the hint
|
|
3203
|
+
* it was, so an orbit never rolls. A state already at the pole is pulled
|
|
3204
|
+
* inside the guard by its first non-zero edit; (0, 0) is a no-op.
|
|
3205
|
+
*
|
|
3206
|
+
* @param {object} cam
|
|
3207
|
+
* @param {number} dAz Azimuth delta, radians.
|
|
3208
|
+
* @param {number} dEl Elevation delta, radians.
|
|
3209
|
+
* @param {{ maxEl?:number }} [opts] Elevation limit; default π/2 − 1e-3.
|
|
3210
|
+
* @returns {object} cam
|
|
3211
|
+
*/
|
|
3212
|
+
function cameraOrbit(cam, dAz, dEl, opts) {
|
|
3213
|
+
if (dAz === 0 && dEl === 0) return cam;
|
|
3214
|
+
const maxEl = Math.PI / 2 - 1e-3;
|
|
3215
|
+
const e = cam.eye, c = cam.center;
|
|
3216
|
+
let ux=cam.up[0], uy=cam.up[1], uz=cam.up[2];
|
|
3217
|
+
const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
|
|
3218
|
+
ux/=ul; uy/=ul; uz/=ul;
|
|
3219
|
+
const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
|
|
3220
|
+
const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
|
|
3221
|
+
_frame(cam);
|
|
3222
|
+
const px=_E$1[4], py=_E$1[5], pz=_E$1[6]; // the eye's up
|
|
3223
|
+
const bx=_E$1[8], by=_E$1[9], bz=_E$1[10]; // back: center → eye, unit
|
|
3224
|
+
// elevation: raise by θ within the (back, up) plane, clamped
|
|
3225
|
+
const el = Math.asin(Math.max(-1, Math.min(1, bx*ux+by*uy+bz*uz)));
|
|
3226
|
+
const th = Math.max(-maxEl, Math.min(maxEl, el + dEl)) - el;
|
|
3227
|
+
const ct = Math.cos(th), st = Math.sin(th);
|
|
3228
|
+
const ox = r*(bx*ct+px*st), oy = r*(by*ct+py*st), oz = r*(bz*ct+pz*st);
|
|
3229
|
+
// azimuth: rotate about the hint (Rodrigues)
|
|
3230
|
+
const ca = Math.cos(dAz), sa = Math.sin(dAz), k = (ux*ox+uy*oy+uz*oz)*(1-ca);
|
|
3231
|
+
e[0] = c[0] + ox*ca + (uy*oz-uz*oy)*sa + ux*k;
|
|
3232
|
+
e[1] = c[1] + oy*ca + (uz*ox-ux*oz)*sa + uy*k;
|
|
3233
|
+
e[2] = c[2] + oz*ca + (ux*oy-uy*ox)*sa + uz*k;
|
|
3234
|
+
return cam;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
/**
|
|
3238
|
+
* Dolly: scale the gaze distance by `factor` — the eye moves along the view
|
|
3239
|
+
* direction under perspective; under orthographic (fov null, halfHeight set)
|
|
3240
|
+
* halfHeight is scaled instead, since moving the eye changes nothing on
|
|
3241
|
+
* screen. opts.min / opts.max clamp the scaled quantity. An edit that would
|
|
3242
|
+
* make it non-positive is a no-op.
|
|
3243
|
+
*
|
|
3244
|
+
* @param {object} cam
|
|
3245
|
+
* @param {number} factor
|
|
3246
|
+
* @param {{ min?:number, max?:number }} [opts]
|
|
3247
|
+
* @returns {object} cam
|
|
3248
|
+
*/
|
|
3249
|
+
function cameraDolly(cam, factor, opts) {
|
|
3250
|
+
const min = opts && typeof opts.min === 'number' ? opts.min : 0;
|
|
3251
|
+
const max = opts && typeof opts.max === 'number' ? opts.max : Infinity;
|
|
3252
|
+
if (cam.fov == null && cam.halfHeight != null) {
|
|
3253
|
+
const h = Math.max(min, Math.min(max, cam.halfHeight * factor));
|
|
3254
|
+
if (h > 0) cam.halfHeight = h;
|
|
3255
|
+
return cam;
|
|
3256
|
+
}
|
|
3257
|
+
const e = cam.eye, c = cam.center;
|
|
3258
|
+
const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
|
|
3259
|
+
const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
|
|
3260
|
+
if (r === 0) return cam;
|
|
3261
|
+
const r1 = Math.max(min, Math.min(max, r * factor));
|
|
3262
|
+
if (!(r1 > 0)) return cam;
|
|
3263
|
+
const s = r1 / r;
|
|
3264
|
+
e[0]=c[0]+dx*s; e[1]=c[1]+dy*s; e[2]=c[2]+dz*s;
|
|
3265
|
+
return cam;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* Pan: translate eye and center by `dx` along the eye's right and `dy` along
|
|
3270
|
+
* its up, world units. The caller converts pixels through pixelRatio at the
|
|
3271
|
+
* center's depth so a pan tracks the pointer.
|
|
3272
|
+
*
|
|
3273
|
+
* @param {object} cam
|
|
3274
|
+
* @param {number} dx
|
|
3275
|
+
* @param {number} dy
|
|
3276
|
+
* @returns {object} cam
|
|
3277
|
+
*/
|
|
3278
|
+
function cameraPan(cam, dx, dy) {
|
|
3279
|
+
_frame(cam);
|
|
3280
|
+
const tx=_E$1[0]*dx+_E$1[4]*dy, ty=_E$1[1]*dx+_E$1[5]*dy, tz=_E$1[2]*dx+_E$1[6]*dy;
|
|
3281
|
+
const e = cam.eye, c = cam.center;
|
|
3282
|
+
e[0]+=tx; e[1]+=ty; e[2]+=tz;
|
|
3283
|
+
c[0]+=tx; c[1]+=ty; c[2]+=tz;
|
|
3284
|
+
return cam;
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3103
3287
|
/**
|
|
3104
3288
|
* @file The view bag — the matrices every pick, solve, label and orbit reads.
|
|
3105
3289
|
* @module host/view
|
|
@@ -4690,6 +4874,738 @@ class TrackHandles {
|
|
|
4690
4874
|
}
|
|
4691
4875
|
}
|
|
4692
4876
|
|
|
4877
|
+
/**
|
|
4878
|
+
* @file Device streams — WebHID and Gamepad rate sources that feed a helm.
|
|
4879
|
+
* @module host/stream
|
|
4880
|
+
* @license AGPL-3.0-only
|
|
4881
|
+
*
|
|
4882
|
+
* The measure-by-subscription transports. Each yields lin[3] / ang[3] in raw
|
|
4883
|
+
* device units and, once bind(helm) ran, calls helm.feed(lin, ang) every
|
|
4884
|
+
* tick of the host's players — the helm's profile does all scaling, so a
|
|
4885
|
+
* stream carries no configuration beyond identification and decoding.
|
|
4886
|
+
*
|
|
4887
|
+
* hid — WebHID. connect() must run from a user gesture; reports land
|
|
4888
|
+
* out of band into the stream's buffers and the frame reads the
|
|
4889
|
+
* latest (last-write-wins). Permissions-Policy gates
|
|
4890
|
+
* requestDevice to top-level documents: a stream in an iframe
|
|
4891
|
+
* without allow="hid" reports available === false and never
|
|
4892
|
+
* prompts.
|
|
4893
|
+
* gamepad — the Gamepad API, polled once per tick from
|
|
4894
|
+
* navigator.getGamepads(); an absent axis reads exactly 0.
|
|
4895
|
+
*
|
|
4896
|
+
* Both expose available and connected for a UI to reflect, and bind(helm),
|
|
4897
|
+
* unbind(), dispose().
|
|
4898
|
+
*/
|
|
4899
|
+
|
|
4900
|
+
|
|
4901
|
+
const _nav = () => (typeof navigator !== 'undefined' ? navigator : null);
|
|
4902
|
+
|
|
4903
|
+
/** The 3Dconnexion vendor ids: the SpaceNavigator and its successors. */
|
|
4904
|
+
const HID_FILTERS = [{ vendorId: 0x046d }, { vendorId: 0x256f }];
|
|
4905
|
+
|
|
4906
|
+
/**
|
|
4907
|
+
* The SpaceNavigator decode: report 1 carries translation, report 2
|
|
4908
|
+
* rotation, each three int16 little-endian lanes; a firmware that packs all
|
|
4909
|
+
* six lanes into one report fills both halves from it.
|
|
4910
|
+
* @param {DataView} report The report's data (the id byte excluded).
|
|
4911
|
+
* @param {number} reportId
|
|
4912
|
+
* @param {number[]} lin Translation lanes, written.
|
|
4913
|
+
* @param {number[]} ang Rotation lanes, written.
|
|
4914
|
+
*/
|
|
4915
|
+
function decodeSpaceNavigator(report, reportId, lin, ang) {
|
|
4916
|
+
if (report.byteLength >= 12) {
|
|
4917
|
+
for (let i = 0; i < 3; i++) { lin[i] = report.getInt16(2 * i, true); ang[i] = report.getInt16(6 + 2 * i, true); }
|
|
4918
|
+
return;
|
|
4919
|
+
}
|
|
4920
|
+
if (report.byteLength < 6) return;
|
|
4921
|
+
const t = reportId === 1 ? lin : reportId === 2 ? ang : null;
|
|
4922
|
+
if (!t) return;
|
|
4923
|
+
for (let i = 0; i < 3; i++) t[i] = report.getInt16(2 * i, true);
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
/** Whether WebHID's requestDevice is allowed in this document. */
|
|
4927
|
+
function _hidAllowed() {
|
|
4928
|
+
if (typeof document !== 'undefined' && document.featurePolicy && typeof document.featurePolicy.allowsFeature === 'function') {
|
|
4929
|
+
return document.featurePolicy.allowsFeature('hid');
|
|
4930
|
+
}
|
|
4931
|
+
if (typeof window !== 'undefined' && 'top' in window) return window.top === window.self;
|
|
4932
|
+
return true;
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
function _stream(host, lin, ang) {
|
|
4936
|
+
let helm = null;
|
|
4937
|
+
const s = {
|
|
4938
|
+
/** The latest translation lanes, raw device units. */
|
|
4939
|
+
lin,
|
|
4940
|
+
/** The latest rotation lanes, raw device units. */
|
|
4941
|
+
ang,
|
|
4942
|
+
/** The bound helm, or null. */
|
|
4943
|
+
get helm() { return helm; },
|
|
4944
|
+
/**
|
|
4945
|
+
* Feed a helm every tick. Chainable.
|
|
4946
|
+
* @param {object} target A PoseHelm (anything with feed(lin, ang)).
|
|
4947
|
+
* @returns {object} this
|
|
4948
|
+
*/
|
|
4949
|
+
bind(target) {
|
|
4950
|
+
if (!target || typeof target.feed !== 'function') {
|
|
4951
|
+
console.error('[host] stream: bind() target must have feed(lin, ang). Leaving unbound.');
|
|
4952
|
+
return s;
|
|
4953
|
+
}
|
|
4954
|
+
helm = target;
|
|
4955
|
+
return s;
|
|
4956
|
+
},
|
|
4957
|
+
/** Stop feeding; the helm sees its last rate until it is fed again. */
|
|
4958
|
+
unbind() { helm = null; return s; },
|
|
4959
|
+
};
|
|
4960
|
+
const feed = () => { if (helm) helm.feed(lin, ang); };
|
|
4961
|
+
return { s, feed };
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
/**
|
|
4965
|
+
* A WebHID rate stream.
|
|
4966
|
+
*
|
|
4967
|
+
* @param {object} host
|
|
4968
|
+
* @param {{ filters?:object[], decode?:function, bind?:object, resume?:boolean,
|
|
4969
|
+
* hid?:object }} [opts]
|
|
4970
|
+
* filters: the requestDevice filters (default HID_FILTERS). decode:
|
|
4971
|
+
* decode(report, reportId, lin, ang) (default decodeSpaceNavigator).
|
|
4972
|
+
* bind: a helm to bind at once. resume (default true): attach a device
|
|
4973
|
+
* this origin was already granted, without a prompt. hid: the WebHID
|
|
4974
|
+
* interface (default navigator.hid) — the seam a test fills.
|
|
4975
|
+
* @returns {object} The stream: { available, connected, device, lin, ang,
|
|
4976
|
+
* connect(), bind(helm), unbind(), dispose() }.
|
|
4977
|
+
*/
|
|
4978
|
+
function createHid(host, opts) {
|
|
4979
|
+
const o = opts || {};
|
|
4980
|
+
const hid = o.hid || (_nav() && _nav().hid) || null;
|
|
4981
|
+
const filters = o.filters || HID_FILTERS;
|
|
4982
|
+
const decode = typeof o.decode === 'function' ? o.decode : decodeSpaceNavigator;
|
|
4983
|
+
const lin = [0, 0, 0], ang = [0, 0, 0];
|
|
4984
|
+
const { s, feed } = _stream(host, lin, ang);
|
|
4985
|
+
let device = null;
|
|
4986
|
+
|
|
4987
|
+
const onReport = (e) => decode(e.data, e.reportId, lin, ang);
|
|
4988
|
+
const onDisconnect = (e) => { if (device && e.device === device) detach(); };
|
|
4989
|
+
const matches = (d) => filters.length === 0 || filters.some((f) =>
|
|
4990
|
+
(f.vendorId == null || f.vendorId === d.vendorId) && (f.productId == null || f.productId === d.productId));
|
|
4991
|
+
|
|
4992
|
+
function detach() {
|
|
4993
|
+
if (!device) return;
|
|
4994
|
+
device.removeEventListener('inputreport', onReport);
|
|
4995
|
+
if (device.opened && typeof device.close === 'function') device.close().catch(() => {});
|
|
4996
|
+
device = null;
|
|
4997
|
+
s.connected = false;
|
|
4998
|
+
lin[0] = lin[1] = lin[2] = ang[0] = ang[1] = ang[2] = 0;
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
async function attach(d) {
|
|
5002
|
+
detach();
|
|
5003
|
+
try { if (!d.opened) await d.open(); }
|
|
5004
|
+
catch (e) { console.error('[host] hid: open() failed: ' + e.message + '.'); return false; }
|
|
5005
|
+
device = d;
|
|
5006
|
+
d.addEventListener('inputreport', onReport);
|
|
5007
|
+
s.connected = true;
|
|
5008
|
+
return true;
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
Object.assign(s, {
|
|
5012
|
+
/** Whether WebHID exists and requestDevice is allowed here. */
|
|
5013
|
+
available: !!(hid && typeof hid.requestDevice === 'function') && _hidAllowed(),
|
|
5014
|
+
/** Whether a device is open and reporting. */
|
|
5015
|
+
connected: false,
|
|
5016
|
+
|
|
5017
|
+
/**
|
|
5018
|
+
* Prompt for a device (a user gesture is required) and attach the choice.
|
|
5019
|
+
* @returns {Promise<boolean>} attached
|
|
5020
|
+
*/
|
|
5021
|
+
async connect() {
|
|
5022
|
+
if (!s.available) { console.error('[host] hid: WebHID is unavailable here (a top-level Chromium document over https or localhost).'); return false; }
|
|
5023
|
+
let devices;
|
|
5024
|
+
try { devices = await hid.requestDevice({ filters }); }
|
|
5025
|
+
catch (e) { console.error('[host] hid: requestDevice rejected: ' + e.message + '.'); return false; }
|
|
5026
|
+
if (!devices || !devices.length) return false;
|
|
5027
|
+
return attach(devices[0]);
|
|
5028
|
+
},
|
|
5029
|
+
|
|
5030
|
+
/**
|
|
5031
|
+
* Attach a device this origin was already granted, without a prompt.
|
|
5032
|
+
* @returns {Promise<boolean>} attached
|
|
5033
|
+
*/
|
|
5034
|
+
async resume() {
|
|
5035
|
+
if (!hid || typeof hid.getDevices !== 'function') return false;
|
|
5036
|
+
let devices;
|
|
5037
|
+
try { devices = await hid.getDevices(); } catch (_) { return false; }
|
|
5038
|
+
const d = (devices || []).find(matches);
|
|
5039
|
+
return d ? attach(d) : false;
|
|
5040
|
+
},
|
|
5041
|
+
|
|
5042
|
+
/** Close the device, remove every listener, unregister from the host. */
|
|
5043
|
+
dispose() {
|
|
5044
|
+
detach();
|
|
5045
|
+
if (hid && typeof hid.removeEventListener === 'function') hid.removeEventListener('disconnect', onDisconnect);
|
|
5046
|
+
host.players.remove(player);
|
|
5047
|
+
host.unregister(s);
|
|
5048
|
+
return s;
|
|
5049
|
+
},
|
|
5050
|
+
});
|
|
5051
|
+
|
|
5052
|
+
/** The open HIDDevice, or null. */
|
|
5053
|
+
Object.defineProperty(s, 'device', { get: () => device, enumerable: true });
|
|
5054
|
+
|
|
5055
|
+
const player = { tick() { feed(); return true; } };
|
|
5056
|
+
host.players.add(player);
|
|
5057
|
+
if (hid && typeof hid.addEventListener === 'function') hid.addEventListener('disconnect', onDisconnect);
|
|
5058
|
+
if (o.bind) s.bind(o.bind);
|
|
5059
|
+
if (s.available && o.resume !== false) s.resume();
|
|
5060
|
+
return host.register(s);
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
/**
|
|
5064
|
+
* The default gamepad map, the standard layout: left stick → lin[0] / lin[1],
|
|
5065
|
+
* right stick → ang[1] / ang[0], the triggers → lin[2] (right minus left).
|
|
5066
|
+
*/
|
|
5067
|
+
const GAMEPAD_MAP = { lin: [0, 1, { buttons: [6, 7] }], ang: [3, 2, null] };
|
|
5068
|
+
|
|
5069
|
+
function _lane(gp, spec) {
|
|
5070
|
+
if (spec == null) return 0;
|
|
5071
|
+
if (typeof spec === 'number') { const v = gp.axes[spec]; return typeof v === 'number' ? v : 0; }
|
|
5072
|
+
if (spec.buttons) {
|
|
5073
|
+
const n = gp.buttons[spec.buttons[0]], p = gp.buttons[spec.buttons[1]];
|
|
5074
|
+
return (p ? p.value : 0) - (n ? n.value : 0);
|
|
5075
|
+
}
|
|
5076
|
+
if (spec.button) { const b = gp.buttons[spec.button]; return b ? b.value : 0; }
|
|
5077
|
+
return 0;
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
/**
|
|
5081
|
+
* A Gamepad rate stream, polled once per tick.
|
|
5082
|
+
*
|
|
5083
|
+
* @param {object} host
|
|
5084
|
+
* @param {{ index?:number, map?:object, bind?:object, gamepads?:function }} [opts]
|
|
5085
|
+
* index: which gamepad (default: the first connected). map: { lin,
|
|
5086
|
+
* ang }, each three lane specs — an axis index, { buttons: [neg,
|
|
5087
|
+
* pos] }, { button } or null (default GAMEPAD_MAP). bind: a helm to
|
|
5088
|
+
* bind at once. gamepads: the poll (default navigator.getGamepads) —
|
|
5089
|
+
* the seam a test fills.
|
|
5090
|
+
* @returns {object} The stream: { available, connected, index, lin, ang,
|
|
5091
|
+
* bind(helm), unbind(), dispose() }.
|
|
5092
|
+
*/
|
|
5093
|
+
function createGamepad(host, opts) {
|
|
5094
|
+
const o = opts || {};
|
|
5095
|
+
const nav = _nav();
|
|
5096
|
+
const poll = o.gamepads || (nav && typeof nav.getGamepads === 'function' ? () => nav.getGamepads() : null);
|
|
5097
|
+
const map = o.map || GAMEPAD_MAP;
|
|
5098
|
+
const lin = [0, 0, 0], ang = [0, 0, 0];
|
|
5099
|
+
const { s, feed } = _stream(host, lin, ang);
|
|
5100
|
+
|
|
5101
|
+
Object.assign(s, {
|
|
5102
|
+
/** Whether the Gamepad API exists. */
|
|
5103
|
+
available: !!poll,
|
|
5104
|
+
/** Whether the last poll found a gamepad. */
|
|
5105
|
+
connected: false,
|
|
5106
|
+
/** The gamepad index polled, or null for the first connected. */
|
|
5107
|
+
index: o.index ?? null,
|
|
5108
|
+
/** Stop polling, unregister from the host. */
|
|
5109
|
+
dispose() { host.players.remove(player); host.unregister(s); return s; },
|
|
5110
|
+
});
|
|
5111
|
+
|
|
5112
|
+
const player = {
|
|
5113
|
+
tick() {
|
|
5114
|
+
let gp = null;
|
|
5115
|
+
if (poll) {
|
|
5116
|
+
const list = poll() || [];
|
|
5117
|
+
if (s.index != null) gp = list[s.index] || null;
|
|
5118
|
+
else for (let i = 0; i < list.length; i++) if (list[i]) { gp = list[i]; break; }
|
|
5119
|
+
}
|
|
5120
|
+
s.connected = !!gp;
|
|
5121
|
+
for (let i = 0; i < 3; i++) {
|
|
5122
|
+
lin[i] = gp ? _lane(gp, map.lin[i]) : 0;
|
|
5123
|
+
ang[i] = gp ? _lane(gp, map.ang[i]) : 0;
|
|
5124
|
+
}
|
|
5125
|
+
feed();
|
|
5126
|
+
return true;
|
|
5127
|
+
},
|
|
5128
|
+
};
|
|
5129
|
+
host.players.add(player);
|
|
5130
|
+
if (o.bind) s.bind(o.bind);
|
|
5131
|
+
return host.register(s);
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5134
|
+
/**
|
|
5135
|
+
* @file Media sources — images, video and Canvas2D rasters for a bridge's textures.
|
|
5136
|
+
* @module host/media
|
|
5137
|
+
* @license AGPL-3.0-only
|
|
5138
|
+
*
|
|
5139
|
+
* What a texture upload reads from: an ImageBitmap fetched from a URL, a
|
|
5140
|
+
* hidden <video> element from a file or the user's camera, a bitmap drawn
|
|
5141
|
+
* through a 2D context (the text path — glyph strips, billboard tags,
|
|
5142
|
+
* anything Canvas2D can typeset). No orientation option anywhere: whether
|
|
5143
|
+
* rows flip is the bridge's upload, since it depends on the GPU API's
|
|
5144
|
+
* texture space.
|
|
5145
|
+
*/
|
|
5146
|
+
|
|
5147
|
+
|
|
5148
|
+
/**
|
|
5149
|
+
* Fetch an image into an ImageBitmap.
|
|
5150
|
+
* @param {string} url
|
|
5151
|
+
* @param {{ fetch?:object, bitmap?:object }} [opts]
|
|
5152
|
+
* fetch: the fetch init (credentials, mode, …). bitmap: the
|
|
5153
|
+
* createImageBitmap options (premultiplyAlpha, colorSpaceConversion, …).
|
|
5154
|
+
* @returns {Promise<ImageBitmap>}
|
|
5155
|
+
*/
|
|
5156
|
+
async function loadImage(url, opts) {
|
|
5157
|
+
const o = opts || {};
|
|
5158
|
+
const res = await fetch(url, o.fetch);
|
|
5159
|
+
if (!res.ok) throw new Error('[host] image: ' + url + ' → ' + res.status);
|
|
5160
|
+
const blob = await res.blob();
|
|
5161
|
+
return o.bitmap ? createImageBitmap(blob, o.bitmap) : createImageBitmap(blob);
|
|
5162
|
+
}
|
|
5163
|
+
|
|
5164
|
+
/**
|
|
5165
|
+
* A hidden, muted, inline <video> element from a file or the user's camera.
|
|
5166
|
+
* `ready` resolves once metadata arrived (width / height known); a camera
|
|
5167
|
+
* denial rejects it. Inside an iframe a camera source needs allow="camera"
|
|
5168
|
+
* on the frame.
|
|
5169
|
+
*
|
|
5170
|
+
* @param {{ src?:string, camera?:boolean|object, loop?:boolean, autoplay?:boolean,
|
|
5171
|
+
* document?:object, media?:object }} opts
|
|
5172
|
+
* src: a media URL. camera: true or getUserMedia video constraints
|
|
5173
|
+
* ({ facingMode, width, height }). loop (default true for src).
|
|
5174
|
+
* autoplay (default true): start() once ready. document / media: the
|
|
5175
|
+
* Document and MediaDevices to use (defaults: the globals) — the seam
|
|
5176
|
+
* a test fills.
|
|
5177
|
+
* @returns {{ el:HTMLVideoElement, ready:Promise<object>, width:number, height:number,
|
|
5178
|
+
* start():Promise<void>, stop():object, dispose():object }}
|
|
5179
|
+
*/
|
|
5180
|
+
function createVideo(opts) {
|
|
5181
|
+
const o = opts || {};
|
|
5182
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
5183
|
+
if (!doc) { console.error('[host] video: no document.'); return null; }
|
|
5184
|
+
const el = doc.createElement('video');
|
|
5185
|
+
el.muted = true;
|
|
5186
|
+
el.playsInline = true;
|
|
5187
|
+
el.setAttribute('playsinline', '');
|
|
5188
|
+
el.setAttribute('muted', '');
|
|
5189
|
+
el.style.display = 'none';
|
|
5190
|
+
let stream = null;
|
|
5191
|
+
|
|
5192
|
+
const v = {
|
|
5193
|
+
/** The <video> element — the bridge's upload source, refreshed each frame. */
|
|
5194
|
+
el,
|
|
5195
|
+
/** Intrinsic size, known once `ready` resolved. */
|
|
5196
|
+
width: 0, height: 0,
|
|
5197
|
+
/** Resolves with this source once metadata arrived; rejects on a camera denial or a load error. */
|
|
5198
|
+
ready: null,
|
|
5199
|
+
/** Play; resolves when playback began. */
|
|
5200
|
+
start() { const p = el.play(); return p && typeof p.catch === 'function' ? p.catch((e) => console.error('[host] video: play() failed: ' + e.message + '.')) : Promise.resolve(); },
|
|
5201
|
+
/** Pause. Chainable. */
|
|
5202
|
+
stop() { el.pause(); return v; },
|
|
5203
|
+
/** Stop, release the camera tracks, detach the element. */
|
|
5204
|
+
dispose() {
|
|
5205
|
+
el.pause();
|
|
5206
|
+
if (stream) { for (const t of stream.getTracks()) t.stop(); stream = null; }
|
|
5207
|
+
el.removeAttribute('src');
|
|
5208
|
+
el.srcObject = null;
|
|
5209
|
+
if (typeof el.remove === 'function') el.remove();
|
|
5210
|
+
return v;
|
|
5211
|
+
},
|
|
5212
|
+
};
|
|
5213
|
+
|
|
5214
|
+
v.ready = new Promise((resolve, reject) => {
|
|
5215
|
+
const unlisten = () => { el.removeEventListener('loadedmetadata', onMeta); el.removeEventListener('error', onErr); };
|
|
5216
|
+
const fail = (msg) => { unlisten(); reject(new Error('[host] video: ' + msg)); };
|
|
5217
|
+
const onMeta = () => {
|
|
5218
|
+
v.width = el.videoWidth || 0; v.height = el.videoHeight || 0;
|
|
5219
|
+
unlisten();
|
|
5220
|
+
if (o.autoplay !== false) v.start();
|
|
5221
|
+
resolve(v);
|
|
5222
|
+
};
|
|
5223
|
+
const onErr = () => fail('failed to load ' + (o.src || 'the source') + '.');
|
|
5224
|
+
el.addEventListener('loadedmetadata', onMeta);
|
|
5225
|
+
el.addEventListener('error', onErr);
|
|
5226
|
+
if (o.camera) {
|
|
5227
|
+
const media = o.media || (typeof navigator !== 'undefined' && navigator.mediaDevices) || null;
|
|
5228
|
+
if (!media || typeof media.getUserMedia !== 'function') { fail('getUserMedia is unavailable here.'); return; }
|
|
5229
|
+
media.getUserMedia({ video: o.camera === true ? true : o.camera, audio: false })
|
|
5230
|
+
.then((s) => { stream = s; el.srcObject = s; })
|
|
5231
|
+
.catch((e) => fail('camera denied: ' + e.message + '.'));
|
|
5232
|
+
} else if (o.src) {
|
|
5233
|
+
el.loop = o.loop !== false;
|
|
5234
|
+
el.src = o.src;
|
|
5235
|
+
} else {
|
|
5236
|
+
fail('needs { src } or { camera }.');
|
|
5237
|
+
}
|
|
5238
|
+
});
|
|
5239
|
+
v.ready.catch(() => {}); // an unobserved rejection is the caller's to observe, not a crash
|
|
5240
|
+
return v;
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
/**
|
|
5244
|
+
* Draw through a 2D context and hand the result back as a texture source: an
|
|
5245
|
+
* ImageBitmap where OffscreenCanvas exists, else the canvas element itself.
|
|
5246
|
+
*
|
|
5247
|
+
* @param {function(CanvasRenderingContext2D, number, number):void} draw
|
|
5248
|
+
* @param {number} w Pixel width.
|
|
5249
|
+
* @param {number} h Pixel height.
|
|
5250
|
+
* @param {{ document?:object }} [opts] The Document for the fallback canvas.
|
|
5251
|
+
* @returns {ImageBitmap|HTMLCanvasElement|null}
|
|
5252
|
+
*/
|
|
5253
|
+
function raster(draw, w, h, opts) {
|
|
5254
|
+
const o = opts || {};
|
|
5255
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
5256
|
+
const c = new OffscreenCanvas(w, h);
|
|
5257
|
+
draw(c.getContext('2d'), w, h);
|
|
5258
|
+
return c.transferToImageBitmap();
|
|
5259
|
+
}
|
|
5260
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
5261
|
+
if (!doc) { console.error('[host] raster: no OffscreenCanvas and no document.'); return null; }
|
|
5262
|
+
const c = doc.createElement('canvas');
|
|
5263
|
+
c.width = w; c.height = h;
|
|
5264
|
+
draw(c.getContext('2d'), w, h);
|
|
5265
|
+
return c;
|
|
5266
|
+
}
|
|
5267
|
+
|
|
5268
|
+
/**
|
|
5269
|
+
* @file The label layer — DOM text over the canvas, anchored in world or screen space.
|
|
5270
|
+
* @module host/labels
|
|
5271
|
+
* @license AGPL-3.0-only
|
|
5272
|
+
*
|
|
5273
|
+
* The replacement for WEBGL text() in HUDs, gizmo labels and formula
|
|
5274
|
+
* readouts, with the browser's own shaping. One absolutely positioned <div>
|
|
5275
|
+
* overlays the canvas as its next sibling, pointer-events: none, sized by
|
|
5276
|
+
* the canvas observer; the host sets the canvas parent to position:
|
|
5277
|
+
* relative when it is static, and says so in the console once.
|
|
5278
|
+
*
|
|
5279
|
+
* labels.set(id, text, x, y, z, opts) // a world anchor, projected each tick()
|
|
5280
|
+
* labels.setScreen(id, text, sx, sy, opts) // canvas px, the HUD form
|
|
5281
|
+
* labels.remove(id) · labels.clear() · labels.visible
|
|
5282
|
+
* labels.tick() // after the view bag is current
|
|
5283
|
+
*
|
|
5284
|
+
* opts: dx / dy pixel offsets, anchor ('center' default, 'left', 'right'),
|
|
5285
|
+
* class — a CSS class beside the layer's own 'host-label', frame — a
|
|
5286
|
+
* transient label that lives for the frame it was set in: a gizmo re-sets
|
|
5287
|
+
* it every draw and tick() removes it once a frame passes without. A label
|
|
5288
|
+
* whose screen depth leaves [0, 1] or whose anchor leaves the canvas is
|
|
5289
|
+
* hidden, not clamped. Elements are reused by id; tick() writes transforms
|
|
5290
|
+
* only, text when the string changed.
|
|
5291
|
+
*/
|
|
5292
|
+
|
|
5293
|
+
|
|
5294
|
+
const _p = [0, 0, 0];
|
|
5295
|
+
const _ANCHOR = { center: -50, left: 0, right: -100 };
|
|
5296
|
+
const _NO_OPTS = {};
|
|
5297
|
+
|
|
5298
|
+
/**
|
|
5299
|
+
* Create the label layer of a canvas. The layer is inserted at once; it
|
|
5300
|
+
* needs a parent, so create it after the canvas is in the document.
|
|
5301
|
+
*
|
|
5302
|
+
* @param {object} host Its canvas, view bag and size are read.
|
|
5303
|
+
* @param {{ document?:object }} [opts] The Document (default: the global).
|
|
5304
|
+
* @returns {object|null} The layer, or null without a document or a parent.
|
|
5305
|
+
*/
|
|
5306
|
+
function createLabels(host, opts) {
|
|
5307
|
+
const o = opts || {};
|
|
5308
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
5309
|
+
const canvas = host.canvas;
|
|
5310
|
+
const parent = canvas && canvas.parentNode;
|
|
5311
|
+
if (!doc || !parent) { console.error('[host] labels: the canvas needs a parent element.'); return null; }
|
|
5312
|
+
|
|
5313
|
+
const el = doc.createElement('div');
|
|
5314
|
+
el.className = 'host-labels';
|
|
5315
|
+
Object.assign(el.style, {
|
|
5316
|
+
position: 'absolute', left: '0px', top: '0px', width: '0px', height: '0px',
|
|
5317
|
+
pointerEvents: 'none', overflow: 'hidden',
|
|
5318
|
+
});
|
|
5319
|
+
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(parent) : null;
|
|
5320
|
+
if (cs && cs.position === 'static') {
|
|
5321
|
+
parent.style.position = 'relative';
|
|
5322
|
+
console.info('[host] labels: set the canvas parent to position: relative for the label layer.');
|
|
5323
|
+
}
|
|
5324
|
+
parent.insertBefore(el, canvas.nextSibling);
|
|
5325
|
+
|
|
5326
|
+
const entries = new Map(); // id → { el, text, x, y, z, dx, dy, ax, screen, cls, sx, sy, shown }
|
|
5327
|
+
let width = 0, height = 0, left = -1, top = -1, visible = true;
|
|
5328
|
+
|
|
5329
|
+
const entry = (id, text, o) => {
|
|
5330
|
+
let e = entries.get(id);
|
|
5331
|
+
if (!e) {
|
|
5332
|
+
const span = doc.createElement('span');
|
|
5333
|
+
Object.assign(span.style, { position: 'absolute', left: '0px', top: '0px', whiteSpace: 'nowrap', willChange: 'transform' });
|
|
5334
|
+
span.style.visibility = 'hidden';
|
|
5335
|
+
el.appendChild(span);
|
|
5336
|
+
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 };
|
|
5337
|
+
entries.set(id, e);
|
|
5338
|
+
}
|
|
5339
|
+
if (e.text !== text) { e.text = text; e.el.textContent = text; }
|
|
5340
|
+
const c = o.class || null;
|
|
5341
|
+
if (e.cls !== c) { e.cls = c; e.el.className = c ? 'host-label ' + c : 'host-label'; }
|
|
5342
|
+
e.ax = _ANCHOR[o.anchor] ?? -50;
|
|
5343
|
+
e.dx = o.dx || 0; e.dy = o.dy || 0;
|
|
5344
|
+
e.frame = !!o.frame; e.fresh = true;
|
|
5345
|
+
return e;
|
|
5346
|
+
};
|
|
5347
|
+
const place = (e, sx, sy) => {
|
|
5348
|
+
if (sx === e.sx && sy === e.sy) return;
|
|
5349
|
+
e.sx = sx; e.sy = sy;
|
|
5350
|
+
e.el.style.transform = 'translate(' + sx + 'px, ' + sy + 'px) translate(' + e.ax + '%, -50%)';
|
|
5351
|
+
};
|
|
5352
|
+
const show = (e, on) => {
|
|
5353
|
+
if (e.shown === on) return;
|
|
5354
|
+
e.shown = on;
|
|
5355
|
+
e.el.style.visibility = on ? '' : 'hidden';
|
|
5356
|
+
};
|
|
5357
|
+
|
|
5358
|
+
const labels = {
|
|
5359
|
+
/** The layer element. */
|
|
5360
|
+
el,
|
|
5361
|
+
/** Whether the layer shows; false hides every label at once. */
|
|
5362
|
+
get visible() { return visible; },
|
|
5363
|
+
set visible(v) { visible = !!v; el.style.display = visible ? '' : 'none'; },
|
|
5364
|
+
/** Live label count. */
|
|
5365
|
+
get size() { return entries.size; },
|
|
5366
|
+
|
|
5367
|
+
/**
|
|
5368
|
+
* Place a label at a world anchor, projected each tick().
|
|
5369
|
+
* @param {string} id
|
|
5370
|
+
* @param {string} text
|
|
5371
|
+
* @param {number} x, y, z World anchor.
|
|
5372
|
+
* @param {{ dx?:number, dy?:number, anchor?:string, class?:string, frame?:boolean }} [opts]
|
|
5373
|
+
* @returns {object} this
|
|
5374
|
+
*/
|
|
5375
|
+
set(id, text, x, y, z, opts) {
|
|
5376
|
+
const e = entry(id, text, opts || _NO_OPTS);
|
|
5377
|
+
e.screen = false; e.x = x; e.y = y; e.z = z;
|
|
5378
|
+
return labels;
|
|
5379
|
+
},
|
|
5380
|
+
|
|
5381
|
+
/**
|
|
5382
|
+
* Place a label in canvas px — the HUD form.
|
|
5383
|
+
* @param {string} id
|
|
5384
|
+
* @param {string} text
|
|
5385
|
+
* @param {number} sx, sy Canvas px, y down.
|
|
5386
|
+
* @param {{ dx?:number, dy?:number, anchor?:string, class?:string, frame?:boolean }} [opts]
|
|
5387
|
+
* @returns {object} this
|
|
5388
|
+
*/
|
|
5389
|
+
setScreen(id, text, sx, sy, opts) {
|
|
5390
|
+
const e = entry(id, text, opts || _NO_OPTS);
|
|
5391
|
+
e.screen = true; e.x = sx; e.y = sy; e.z = 0;
|
|
5392
|
+
return labels;
|
|
5393
|
+
},
|
|
5394
|
+
|
|
5395
|
+
/** Remove a label. */
|
|
5396
|
+
remove(id) {
|
|
5397
|
+
const e = entries.get(id);
|
|
5398
|
+
if (e) { el.removeChild(e.el); entries.delete(id); }
|
|
5399
|
+
return labels;
|
|
5400
|
+
},
|
|
5401
|
+
|
|
5402
|
+
/** Remove every label. */
|
|
5403
|
+
clear() { for (const id of [...entries.keys()]) labels.remove(id); return labels; },
|
|
5404
|
+
|
|
5405
|
+
/**
|
|
5406
|
+
* Project every world label through the view bag and write the
|
|
5407
|
+
* transforms; hide what falls outside the canvas or the depth range.
|
|
5408
|
+
* Call after the view bag is current for the frame.
|
|
5409
|
+
* @returns {object} this
|
|
5410
|
+
*/
|
|
5411
|
+
tick() {
|
|
5412
|
+
const ol = canvas.offsetLeft || 0, ot = canvas.offsetTop || 0;
|
|
5413
|
+
if (ol !== left || ot !== top) { left = ol; top = ot; el.style.left = ol + 'px'; el.style.top = ot + 'px'; }
|
|
5414
|
+
const view = host.view;
|
|
5415
|
+
for (const [id, e] of entries) {
|
|
5416
|
+
if (e.frame && !e.fresh) { labels.remove(id); continue; }
|
|
5417
|
+
e.fresh = false;
|
|
5418
|
+
let sx, sy, on;
|
|
5419
|
+
if (e.screen) { sx = e.x; sy = e.y; on = true; }
|
|
5420
|
+
else if (!view || view.stale) { on = false; }
|
|
5421
|
+
else {
|
|
5422
|
+
mapLocation(_p, e.x, e.y, e.z, WORLD, SCREEN, view, view.vp, view.ndcZMin);
|
|
5423
|
+
sx = _p[0]; sy = _p[1];
|
|
5424
|
+
on = _p[2] >= 0 && _p[2] <= 1 && sx >= 0 && sx <= width && sy >= 0 && sy <= height;
|
|
5425
|
+
}
|
|
5426
|
+
if (on) place(e, sx + e.dx, sy + e.dy);
|
|
5427
|
+
show(e, on);
|
|
5428
|
+
}
|
|
5429
|
+
return labels;
|
|
5430
|
+
},
|
|
5431
|
+
|
|
5432
|
+
/**
|
|
5433
|
+
* The observer's write: the layer's size in CSS px.
|
|
5434
|
+
* @param {number} w
|
|
5435
|
+
* @param {number} h
|
|
5436
|
+
* @returns {object} this
|
|
5437
|
+
*/
|
|
5438
|
+
resize(w, h) {
|
|
5439
|
+
width = w; height = h;
|
|
5440
|
+
el.style.width = w + 'px'; el.style.height = h + 'px';
|
|
5441
|
+
return labels;
|
|
5442
|
+
},
|
|
5443
|
+
|
|
5444
|
+
/** Remove the layer and its labels. */
|
|
5445
|
+
dispose() {
|
|
5446
|
+
entries.clear();
|
|
5447
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
5448
|
+
host.unregister(labels);
|
|
5449
|
+
return labels;
|
|
5450
|
+
},
|
|
5451
|
+
};
|
|
5452
|
+
labels.resize(host.width || 0, host.height || 0);
|
|
5453
|
+
return host.register(labels);
|
|
5454
|
+
}
|
|
5455
|
+
|
|
5456
|
+
/**
|
|
5457
|
+
* @file The orbit — the fall-through camera gesture on unclaimed pointers.
|
|
5458
|
+
* @module host/orbit
|
|
5459
|
+
* @license AGPL-3.0-only
|
|
5460
|
+
*
|
|
5461
|
+
* What the handle gate yields to: one pointer orbits the camera state about
|
|
5462
|
+
* its center, two pointers dolly by their distance and pan by their
|
|
5463
|
+
* midpoint, the wheel dollies. update() consumes only unclaimed pointers and
|
|
5464
|
+
* returns true when it moved the camera, so
|
|
5465
|
+
*
|
|
5466
|
+
* if (!h.update()) orbit.update()
|
|
5467
|
+
*
|
|
5468
|
+
* reads as the gate. It writes cam only; the next setCamera(cam) installs it.
|
|
5469
|
+
* Two-pointer motion is computed from the pointers' own coordinates, never
|
|
5470
|
+
* from a browser's pinch synthesis, so it reads the same on every platform;
|
|
5471
|
+
* the canvas gets touch-action: none while the orbit lives so the browser
|
|
5472
|
+
* does not scroll or zoom the page instead. No damping, no inertia: direct
|
|
5473
|
+
* manipulation wants exactness, and a sketch that wants smoothing lerps the
|
|
5474
|
+
* camera it owns.
|
|
5475
|
+
*
|
|
5476
|
+
* Screen y runs down; which world direction that is depends on the
|
|
5477
|
+
* projection installed in the view bag: a y-up projection (GL's, tree's
|
|
5478
|
+
* default) maps a downward drag to the eye's −up, a y-flipped one (p5's,
|
|
5479
|
+
* mat4Proj[5] < 0) to +up. The orbit reads that sign off the bag each
|
|
5480
|
+
* update, so a drag down always brings the scene down.
|
|
5481
|
+
*/
|
|
5482
|
+
|
|
5483
|
+
|
|
5484
|
+
const _clamp = { min: 0, max: Infinity };
|
|
5485
|
+
|
|
5486
|
+
/**
|
|
5487
|
+
* Create an orbit on a camera state.
|
|
5488
|
+
*
|
|
5489
|
+
* @param {object} host
|
|
5490
|
+
* @param {object} cam The camera state written.
|
|
5491
|
+
* @param {{ rotate?:number, pan?:number, zoom?:number, wheel?:number,
|
|
5492
|
+
* minDistance?:number, maxDistance?:number, enabled?:boolean }} [opts]
|
|
5493
|
+
* rotate: radians per pixel (default 0.005). pan / zoom / wheel:
|
|
5494
|
+
* scalars on the two-pointer pan, the pinch and the wheel dolly
|
|
5495
|
+
* (default 1). minDistance / maxDistance: gaze-distance clamps.
|
|
5496
|
+
* @returns {object} The orbit: { cam, enabled, rotate, pan, zoom, wheel,
|
|
5497
|
+
* minDistance, maxDistance, update(), home(), dispose() }.
|
|
5498
|
+
*/
|
|
5499
|
+
function createOrbit(host, cam, opts) {
|
|
5500
|
+
const o = opts || {};
|
|
5501
|
+
const canvas = host.canvas;
|
|
5502
|
+
const home = cameraCopy(createCamera(), cam);
|
|
5503
|
+
const tracked = new Map(); // pointer id → { x, y } as last consumed
|
|
5504
|
+
let wheelAcc = 0;
|
|
5505
|
+
|
|
5506
|
+
const onWheel = (e) => {
|
|
5507
|
+
if (typeof e.preventDefault === 'function') e.preventDefault();
|
|
5508
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? (host.height || 1) : 1;
|
|
5509
|
+
wheelAcc += (e.deltaY || 0) * unit;
|
|
5510
|
+
};
|
|
5511
|
+
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
5512
|
+
const touchAction = canvas.style ? canvas.style.touchAction : undefined;
|
|
5513
|
+
if (canvas.style) canvas.style.touchAction = 'none';
|
|
5514
|
+
|
|
5515
|
+
const track = (p) => {
|
|
5516
|
+
let t = tracked.get(p.id);
|
|
5517
|
+
if (!t) { t = { x: p.x, y: p.y }; tracked.set(p.id, t); }
|
|
5518
|
+
else { t.x = p.x; t.y = p.y; }
|
|
5519
|
+
};
|
|
5520
|
+
const distance = () => {
|
|
5521
|
+
const e = cam.eye, c = cam.center;
|
|
5522
|
+
return Math.hypot(e[0] - c[0], e[1] - c[1], e[2] - c[2]);
|
|
5523
|
+
};
|
|
5524
|
+
const dolly = (factor) => {
|
|
5525
|
+
_clamp.min = orbit.minDistance; _clamp.max = orbit.maxDistance;
|
|
5526
|
+
cameraDolly(cam, factor, _clamp);
|
|
5527
|
+
};
|
|
5528
|
+
|
|
5529
|
+
const orbit = {
|
|
5530
|
+
/** The camera state written. */
|
|
5531
|
+
cam,
|
|
5532
|
+
/** False makes update() a no-op that drops its pointer memory. */
|
|
5533
|
+
enabled: o.enabled !== false,
|
|
5534
|
+
/** Radians per pixel of one-pointer drag. */
|
|
5535
|
+
rotate: o.rotate ?? 0.005,
|
|
5536
|
+
/** Scalar on the two-pointer pan. */
|
|
5537
|
+
pan: o.pan ?? 1,
|
|
5538
|
+
/** Scalar on the pinch dolly's exponent. */
|
|
5539
|
+
zoom: o.zoom ?? 1,
|
|
5540
|
+
/** Scalar on the wheel dolly. */
|
|
5541
|
+
wheel: o.wheel ?? 1,
|
|
5542
|
+
/** Gaze-distance clamps for every dolly. */
|
|
5543
|
+
minDistance: o.minDistance ?? 0,
|
|
5544
|
+
maxDistance: o.maxDistance ?? Infinity,
|
|
5545
|
+
|
|
5546
|
+
/**
|
|
5547
|
+
* Consume this frame's unclaimed pointer motion and wheel into cam.
|
|
5548
|
+
* @returns {boolean} Whether the camera moved.
|
|
5549
|
+
*/
|
|
5550
|
+
update() {
|
|
5551
|
+
if (!orbit.enabled) { tracked.clear(); wheelAcc = 0; return false; }
|
|
5552
|
+
const src = host.pointer;
|
|
5553
|
+
let a = null, b = null;
|
|
5554
|
+
for (const p of src.pointers.values()) {
|
|
5555
|
+
if (p.owner !== null || p.up || p.cancel) { tracked.delete(p.id); continue; }
|
|
5556
|
+
if (a === null || p.id < a.id) { b = a; a = p; }
|
|
5557
|
+
else if (b === null || p.id < b.id) b = p;
|
|
5558
|
+
}
|
|
5559
|
+
for (const id of tracked.keys()) {
|
|
5560
|
+
if ((a === null || id !== a.id) && (b === null || id !== b.id)) tracked.delete(id);
|
|
5561
|
+
}
|
|
5562
|
+
let moved = false;
|
|
5563
|
+
const view = host.view;
|
|
5564
|
+
const ys = view.mat4Proj[5] < 0 ? -1 : 1; // screen-down is the eye's −up under a y-up projection, +up under p5's flip
|
|
5565
|
+
if (a !== null && b === null) {
|
|
5566
|
+
const t = tracked.get(a.id);
|
|
5567
|
+
if (t) {
|
|
5568
|
+
const dx = a.x - t.x, dy = a.y - t.y;
|
|
5569
|
+
if (dx !== 0 || dy !== 0) { cameraOrbit(cam, -dx * orbit.rotate, ys * dy * orbit.rotate); moved = true; }
|
|
5570
|
+
}
|
|
5571
|
+
track(a);
|
|
5572
|
+
} else if (a !== null && b !== null) {
|
|
5573
|
+
const ta = tracked.get(a.id), tb = tracked.get(b.id);
|
|
5574
|
+
if (ta && tb) {
|
|
5575
|
+
const dmx = (a.x + b.x - ta.x - tb.x) / 2, dmy = (a.y + b.y - ta.y - tb.y) / 2;
|
|
5576
|
+
if (dmx !== 0 || dmy !== 0) {
|
|
5577
|
+
const ratio = pixelRatio(view.mat4Proj, -view.vp[3] || 1, -distance(), view.ndcZMin);
|
|
5578
|
+
cameraPan(cam, -dmx * ratio * orbit.pan, ys * dmy * ratio * orbit.pan);
|
|
5579
|
+
moved = true;
|
|
5580
|
+
}
|
|
5581
|
+
const d0 = Math.hypot(ta.x - tb.x, ta.y - tb.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);
|
|
5582
|
+
if (d0 > 0 && d1 > 0 && d0 !== d1) { dolly(Math.pow(d0 / d1, orbit.zoom)); moved = true; }
|
|
5583
|
+
}
|
|
5584
|
+
track(a); track(b);
|
|
5585
|
+
}
|
|
5586
|
+
if (wheelAcc !== 0) {
|
|
5587
|
+
dolly(Math.exp(wheelAcc * 0.001 * orbit.wheel));
|
|
5588
|
+
wheelAcc = 0;
|
|
5589
|
+
moved = true;
|
|
5590
|
+
}
|
|
5591
|
+
return moved;
|
|
5592
|
+
},
|
|
5593
|
+
|
|
5594
|
+
/** Restore the pose captured at creation. */
|
|
5595
|
+
home() { cameraCopy(cam, home); return orbit; },
|
|
5596
|
+
|
|
5597
|
+
/** Remove the wheel listener, restore touch-action, unregister from the host. */
|
|
5598
|
+
dispose() {
|
|
5599
|
+
canvas.removeEventListener('wheel', onWheel);
|
|
5600
|
+
if (canvas.style) canvas.style.touchAction = touchAction;
|
|
5601
|
+
tracked.clear();
|
|
5602
|
+
host.unregister(orbit);
|
|
5603
|
+
return orbit;
|
|
5604
|
+
},
|
|
5605
|
+
};
|
|
5606
|
+
return host.register(orbit);
|
|
5607
|
+
}
|
|
5608
|
+
|
|
4693
5609
|
/**
|
|
4694
5610
|
* @file The host context — one per canvas: pointer, view bag, players, loop, observer.
|
|
4695
5611
|
* @module host
|
|
@@ -4706,6 +5622,10 @@ class TrackHandles {
|
|
|
4706
5622
|
* host.handle(opts) · host.router(handles, opts)
|
|
4707
5623
|
* host.cameraHelm(cam, opts) · host.poseHelm(opts)
|
|
4708
5624
|
* host.poseTrack(opts) · host.cameraTrack(cam, opts)
|
|
5625
|
+
* host.hid(opts) · host.gamepad(opts)
|
|
5626
|
+
* host.image(url) · host.video(opts) · host.raster(draw, w, h)
|
|
5627
|
+
* host.labels // the DOM label layer, created on first access
|
|
5628
|
+
* host.orbit(cam, opts)
|
|
4709
5629
|
* host.tick(dt) // external-loop mode
|
|
4710
5630
|
* host.dispose()
|
|
4711
5631
|
*
|
|
@@ -4740,7 +5660,7 @@ function createHost(canvas, opts) {
|
|
|
4740
5660
|
const o = opts || {};
|
|
4741
5661
|
const t0 = _now();
|
|
4742
5662
|
const constructs = new Set();
|
|
4743
|
-
let loop = null, observer = null;
|
|
5663
|
+
let loop = null, observer = null, labels = null;
|
|
4744
5664
|
const host = {
|
|
4745
5665
|
canvas,
|
|
4746
5666
|
width: 0, height: 0, dpr: 1,
|
|
@@ -4788,6 +5708,28 @@ function createHost(canvas, opts) {
|
|
|
4788
5708
|
poseTrack(opts) { return poseTrack(host, opts); },
|
|
4789
5709
|
/** A core CameraTrack evaluating into a camera state while it plays — see host/track. */
|
|
4790
5710
|
cameraTrack(cam, opts) { return cameraTrack(host, cam, opts); },
|
|
5711
|
+
/** A WebHID 6-DOF rate stream — see host/stream. */
|
|
5712
|
+
hid(opts) { return createHid(host, opts); },
|
|
5713
|
+
/** A Gamepad rate stream, polled each tick — see host/stream. */
|
|
5714
|
+
gamepad(opts) { return createGamepad(host, opts); },
|
|
5715
|
+
/** Fetch an image into an ImageBitmap — see host/media. */
|
|
5716
|
+
image(url, opts) { return loadImage(url, opts); },
|
|
5717
|
+
/** A hidden <video> from a file or the camera, disposed with the host — see host/media. */
|
|
5718
|
+
video(opts) { return host.register(createVideo(opts)); },
|
|
5719
|
+
/** Draw through a 2D context into a texture source — see host/media. */
|
|
5720
|
+
raster(draw, w, h, opts) { return raster(draw, w, h, opts); },
|
|
5721
|
+
/**
|
|
5722
|
+
* The label layer — see host/labels. Created on first access, so a
|
|
5723
|
+
* canvas that never labels never gets a layer nor a repositioned parent.
|
|
5724
|
+
*/
|
|
5725
|
+
get labels() {
|
|
5726
|
+
if (labels === null) labels = createLabels(host);
|
|
5727
|
+
return labels;
|
|
5728
|
+
},
|
|
5729
|
+
/** Whether the label layer exists, without creating it. */
|
|
5730
|
+
get hasLabels() { return labels !== null; },
|
|
5731
|
+
/** The fall-through camera gesture on unclaimed pointers — see host/orbit. */
|
|
5732
|
+
orbit(cam, opts) { return createOrbit(host, cam, opts); },
|
|
4791
5733
|
|
|
4792
5734
|
/** Register a construct with dispose() so host.dispose() releases it. */
|
|
4793
5735
|
register(c) { if (c) constructs.add(c); return c; },
|
|
@@ -4799,6 +5741,7 @@ function createHost(canvas, opts) {
|
|
|
4799
5741
|
if (loop) loop.stop();
|
|
4800
5742
|
for (const c of [...constructs]) { if (typeof c.dispose === 'function') c.dispose(); }
|
|
4801
5743
|
constructs.clear();
|
|
5744
|
+
labels = null;
|
|
4802
5745
|
observer.dispose();
|
|
4803
5746
|
host.pointer.dispose();
|
|
4804
5747
|
host.players.clear();
|
|
@@ -4811,6 +5754,7 @@ function createHost(canvas, opts) {
|
|
|
4811
5754
|
observer = observeCanvas(canvas, (w, h, dpr) => {
|
|
4812
5755
|
host.width = w; host.height = h; host.dpr = dpr;
|
|
4813
5756
|
host.view.resize(w, h);
|
|
5757
|
+
if (labels) labels.resize(w, h);
|
|
4814
5758
|
if (typeof o.onSize === 'function') o.onSize(w, h, dpr);
|
|
4815
5759
|
});
|
|
4816
5760
|
|
|
@@ -4829,5 +5773,5 @@ function createHost(canvas, opts) {
|
|
|
4829
5773
|
return host;
|
|
4830
5774
|
}
|
|
4831
5775
|
|
|
4832
|
-
export { Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createHost, createLoop, createPlayers, createPointer, createView, helmBasis, isConstraint, measureCanvas, observeCanvas, poseHelm, poseTrack, validConstraint };
|
|
5776
|
+
export { GAMEPAD_MAP, HID_FILTERS, Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createGamepad, createHid, createHost, createLabels, createLoop, createOrbit, createPlayers, createPointer, createVideo, createView, decodeSpaceNavigator, helmBasis, isConstraint, loadImage, measureCanvas, observeCanvas, poseHelm, poseTrack, raster, validConstraint };
|
|
4833
5777
|
//# sourceMappingURL=index.js.map
|