@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/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