@nakednous/tree 0.0.25 → 0.0.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@nakednous/tree`
2
2
 
3
- Pure numeric core for animation, coordinate-space mapping, and visibility — **zero dependencies**, runs anywhere.
3
+ Pure numeric core for animation, rate-driven control, coordinate-space mapping, and visibility — **zero dependencies**, runs anywhere.
4
4
 
5
5
  ---
6
6
 
@@ -33,13 +33,15 @@ import * as tree from '@nakednous/tree'
33
33
 
34
34
  The dependency direction is strict: `@nakednous/tree` never imports from the bridge or the DOM layer. This is what lets the same `PoseTrack` that drives a camera path also animate any object — headless, server-side, or in a future renderer.
35
35
 
36
- Source is organised into five focused modules:
36
+ Source is organised into six focused modules:
37
37
 
38
38
  ```
39
39
  form.js — you have specs, you want a matrix
40
40
  query.js — you have a matrix, you want information
41
41
  quat.js — quaternion algebra and mat4/mat3 conversions
42
42
  track.js — spline math and keyframe animation state machines
43
+ helm.js — 6-DOF rate-stream integrator — the Track family's live-input sibling
44
+ filter.js — input conditioning: the 1€ filter + absolute→rate differencing
43
45
  handle.js — constraint solver + ray primitives for interactive manipulators
44
46
  ```
45
47
 
@@ -296,6 +298,109 @@ One-keyframe behaviour: `play()` with exactly one keyframe snaps `eval()` to tha
296
298
 
297
299
  ---
298
300
 
301
+ ### PoseHelm — 6-DOF rate-driven pose
302
+
303
+ The rate-stream sibling of the Track family. Where a track produces a pose from keyframes over time, a `PoseHelm` produces one from a live 6-DOF delta stream — a SpaceNavigator, a tracked hand, an agent policy. It holds a profile plus the integrated pose; there is no timeline (no keyframes, no `play` / `seek` / `loop`), and it never learns about a camera — the host hands it a resolved `basis` each step.
304
+
305
+ ```js
306
+ import { PoseHelm } from '@nakednous/tree'
307
+
308
+ const helm = new PoseHelm()
309
+ const out = { pos: [0,0,0], rot: [0,0,0,1] }
310
+
311
+ // a transport feeds raw lane rates — either half may be omitted:
312
+ helm.feed([tx, ty, tz], [rx, ry, rz])
313
+
314
+ // per-frame — host-driven, zero allocation:
315
+ helm.step(out, dt, basis) // integrate dt seconds, write the new { pos, rot }
316
+ helm.eval(out) // read the current pose without integrating
317
+ ```
318
+
319
+ `feed` is the input (as `add` is a track's); `step` + `eval` parallel `tick` + `eval`. `step` is host-driven — the bridge calls it each frame, exactly as a sketch never calls `track.tick()`.
320
+
321
+ **Profile — sign · sens · lane.** The whole sign / sensitivity / axis-map question is one flat declarative object. Six channels — three translation (Tx Ty Tz), three rotation (Rp pitch, Ry yaw, Rr roll) — each `{ sign, sens, lane }`:
322
+
323
+ ```js
324
+ helm.profile = {
325
+ Tx: { sign: +1, sens: 0.30, lane: 0 }, // lane = which fed channel drives +X
326
+ Ty: { sign: +1, sens: 0.30, lane: 2 },
327
+ Tz: { sign: -1, sens: 0.30, lane: 1 },
328
+ Rp: { sign: -1, sens: 0.0025, lane: 0 },
329
+ Ry: { sign: -1, sens: 0.0025, lane: 2 },
330
+ Rr: { sign: +1, sens: 0.0018, lane: 1 },
331
+ }
332
+ ```
333
+
334
+ - **sign** — per-app direction (camera-fly vs object-grab invert).
335
+ - **sens** — per-axis sensitivity (tame roll without touching the rest).
336
+ - **lane** — input-channel permutation: which fed channel drives this DOF. `T*` lanes index the translation triple, `R*` the rotation triple.
337
+
338
+ `sens` does all the scaling, so the same raw `feed()` suits any transport — only the profile changes. The default is SpaceNavigator-tuned and meant to be replaced wholesale for a different device. `HELM_CHANNELS` is the frozen order `['Tx','Ty','Tz','Rp','Ry','Rr']`.
339
+
340
+ **Frame — `from`.** `helm.from` names the space fed rates are interpreted in — a declaration the host reads to resolve the per-step `basis` (the core stays camera-agnostic):
341
+
342
+ ```
343
+ WORLD world axes — the identity basis (step's basis is null)
344
+ EYE a viewing camera's frame — screen-relative (default)
345
+ SELF the helm's OWN evolving pose — body-relative
346
+ <mat4> an explicit fixed frame
347
+ ```
348
+
349
+ `step` rotates both linear and angular rates through `basis`, then composes the quaternion world-frame — one code path covering body-fly and screen-relative manipulation. `SELF` is body-relative (a per-frame-rebuilt pose matrix); it is a helm `from` value only, not a general mapping space.
350
+
351
+ **Rest of the surface.**
352
+
353
+ ```js
354
+ helm.deadzone = 8 // rest-drift floor — |rate| ≤ deadzone reads as 0
355
+ helm.filter = oneEuro({ minCutoff: 1, beta: 0.5 }) // optional input conditioner (filter → deadzone)
356
+ helm.fullScale = 500 // raw full-deflection magnitude a read-out divides by
357
+ helm.activity(out6) // six effective rates (post deadzone·sign·sens), channel order
358
+ helm.home([pose]) // re-home pos + rot (NOT reset — no keyframes); clears pending rate; resets filter
359
+ ```
360
+
361
+ `filter` is an optional input conditioner (default `null`): when set, `step` runs it over the fed rate before the deadzone — filter then deadzone, the two orthogonal (the 1€ removes zero-mean jitter; the deadzone's exact zero is the only no-creep guarantee, since a low-pass passes DC). `fullScale` (default `500`) is the raw full-deflection magnitude a read-out divides by, so a transport on a different input scale declares its own and its meters read honestly. `activity()` reports the raw fed rate (pre-filter) by design.
362
+
363
+ The `p5.tree` bridge wraps this into `createCameraHelm` / `createPoseHelm` (transport, camera basis, draw-loop player) plus the `helmRig` gizmo and the `createPanel(helm)` profile editor.
364
+
365
+ ---
366
+
367
+ ### Input conditioning — `oneEuro` · `poseDelta`
368
+
369
+ Two helpers for the rate stream a helm feeds on — flat, out-first, zero-alloc (`filter.js`).
370
+
371
+ **`oneEuro({ minCutoff, beta, dCutoff })`** is the [1€ filter](https://gery.casiez.net/1euro/) (Casiez et al., CHI'12): a first-order low-pass whose cutoff rises with signal speed — heavy smoothing at rest, low lag under motion. It returns a stateful carrying function, dispatched on its first argument:
372
+
373
+ ```js
374
+ import { oneEuro } from '@nakednous/tree'
375
+
376
+ const f = oneEuro({ minCutoff: 1, beta: 0.5 }) // params live-mutable: f.minCutoff, f.beta
377
+
378
+ // scalar form — returns the filtered number
379
+ const y = f(rawScalar, dt)
380
+
381
+ // vec form — out-first, zero-alloc after warm-up
382
+ const out = [0, 0, 0]
383
+ f(out, rawVec3, dt)
384
+
385
+ f.reset() // drop state — next call re-seeds
386
+ ```
387
+
388
+ It removes zero-mean **jitter**, not a DC **bias**: a low-pass passes a constant offset, so a resting bias survives it and still integrates to drift — pair it with a deadzone (the helm applies filter → deadzone in that order).
389
+
390
+ **`poseDelta(out, prev, cur, dt)`** differences two absolute poses into the `{ lin, ang }` rate a helm feeds on — the bridge from an absolute transport (a tracked hand, a marker, a played keyframe) to the rate stream.
391
+
392
+ ```js
393
+ import { poseDelta } from '@nakednous/tree'
394
+
395
+ const rate = { lin: [0, 0, 0], ang: [0, 0, 0] }
396
+ poseDelta(rate, prevPose, curPose, dt) // prev / cur: { pos:[x,y,z], rot:[x,y,z,w] }
397
+ helm.feed(rate.lin, rate.ang)
398
+ ```
399
+
400
+ The angular half carries a **double-cover guard**: a quaternion and its negation are the same orientation, so a source that returns a canonicalised quaternion makes the stored value jump hemispheres as the true orientation sweeps through `w = 0`. When `dot(prev, cur) < 0`, `poseDelta` flips `cur` into `prev`'s hemisphere before differencing, so the relative rotation always takes the short arc — without it the angular rate spikes toward `2π/dt` at every crossing.
401
+
402
+ ---
403
+
299
404
  ### Coordinate-space mapping
300
405
 
301
406
  `mapLocation` and `mapDirection` convert points and vectors between any pair of named spaces. All work is done in flat scalar arithmetic — no objects created per call.
@@ -455,6 +560,9 @@ projLeft projRight projTop projBottom
455
560
  // Coordinate spaces
456
561
  WORLD, EYE, NDC, SCREEN, MODEL, MATRIX
457
562
 
563
+ // Helm integrator frame (helm `from` only — body-relative, not a mapping space)
564
+ SELF
565
+
458
566
  // NDC Z convention
459
567
  WEBGL // −1 (z ∈ [−1, 1])
460
568
  WEBGPU // 0 (z ∈ [0, 1])
package/dist/index.js CHANGED
@@ -12,6 +12,10 @@ const SCREEN = 'SCREEN';
12
12
  const MODEL = 'MODEL';
13
13
  const MATRIX = 'MATRIX';
14
14
 
15
+ // Integrator frame source (helm `from` only): the construct's own current pose
16
+ // — body-relative; not a general mapDirection space.
17
+ const SELF = 'SELF';
18
+
15
19
  // NDC Z convention (only difference between backends)
16
20
  const WEBGL = -1; // z ∈ [−1, 1]
17
21
  const WEBGPU = 0; // z ∈ [0, 1]
@@ -289,6 +293,117 @@ const qToAxisAngle = (q, out) => {
289
293
  return out;
290
294
  };
291
295
 
296
+ /**
297
+ * @file 1€ input filter — speed-adaptive first-order low-pass for noisy input.
298
+ * @module tree/filter
299
+ * @license AGPL-3.0-only
300
+ *
301
+ * A jitter conditioner for noisy / absolute input streams (the helm's optional
302
+ * `filter` slot; sketch-side handle conditioning). It is NOT a deadzone
303
+ * replacement: a low-pass passes DC, so a constant rest bias survives it and
304
+ * still integrates to creep — only the deadzone's exact-zero clamp removes that.
305
+ * The 1€ removes zero-mean jitter; the two are orthogonal and coexist, applied
306
+ * filter → deadzone (condition the continuous signal first, then gate to zero).
307
+ *
308
+ * First-order low-pass whose cutoff rises with the signal's speed: slow (rest)
309
+ * → low cutoff → heavy smoothing; fast (motion) → high cutoff → low lag. Tuned
310
+ * by `minCutoff` (Hz, the rest cutoff) and `beta` (how fast the cutoff opens
311
+ * with speed). Reimplemented from the paper's equations — `tree` is zero-dep, so
312
+ * the ~one-page primitive is vendored, not depended on (the same call made for
313
+ * quaternions over gl-matrix).
314
+ *
315
+ * Reference: Casiez, G., Roussel, N., & Vogel, D. (2012). 1€ Filter: A Simple
316
+ * Speed-based Low-pass Filter for Noisy Input in Interactive Systems. CHI '12,
317
+ * 2527–2530. DOI 10.1145/2207676.2208639 · https://gery.casiez.net/1euro/
318
+ */
319
+
320
+
321
+ /**
322
+ * Build a stateful 1€ filter as an out-first carrying function.
323
+ *
324
+ * The returned `f` carries the previous filtered value and derivative across
325
+ * calls (one state slot per vector component), and is used in one of two forms:
326
+ *
327
+ * - vec form `f(out, raw, dt)` — `out`/`raw` are equal-length ArrayLikes;
328
+ * writes the filtered vector into `out` and returns it.
329
+ * Zero-allocation after the first call (state sized to `raw`).
330
+ * - scalar form `f(raw, dt)` — `raw` is a number; returns the filtered number.
331
+ *
332
+ * The form is chosen by the first argument's type (number ⇒ scalar). `minCutoff`,
333
+ * `beta`, and `dCutoff` are live-mutable on the returned function (`f.minCutoff`,
334
+ * `f.beta`, `f.dCutoff`) and read every call, so a panel can tune them against
335
+ * live noise. `f.reset()` drops the carried state at a discontinuity (a
336
+ * re-acquired source, a helm `home()`); smear across one and the output lurches.
337
+ *
338
+ * @param {Object} [opts]
339
+ * @param {number} [opts.minCutoff=1] Minimum (rest) cutoff frequency, in Hz.
340
+ * @param {number} [opts.beta=0] Speed coefficient (cutoff = minCutoff +
341
+ * beta·|filtered derivative|); unitless.
342
+ * @param {number} [opts.dCutoff=1] Derivative cutoff frequency, in Hz.
343
+ * @returns {Function} A carrying filter `f(out, raw, dt)` / `f(raw, dt)` with a
344
+ * `reset()` method and live `minCutoff` / `beta` / `dCutoff`.
345
+ */
346
+ function oneEuro(opts) {
347
+ opts = opts || {};
348
+
349
+ // Carried state: previous filtered value + previous filtered derivative.
350
+ // number (scalar form) | number[] (vec form). primed = state is seeded.
351
+ let xPrev = null;
352
+ let dxPrev = null;
353
+ let primed = false;
354
+
355
+ // Low-pass smoothing factor for a sample period `dt` and a cutoff frequency
356
+ // (the paper's α = 1 / (1 + τ/Te), τ = 1 / (2π·cutoff)).
357
+ const alpha = (dt, cutoff) => {
358
+ const tau = 1 / (2 * Math.PI * cutoff);
359
+ return 1 / (1 + tau / dt);
360
+ };
361
+ const smooth = (a, x, xp) => a * x + (1 - a) * xp;
362
+
363
+ const scalar = (x, dt) => {
364
+ if (!primed) { xPrev = x; dxPrev = 0; primed = true; return x; }
365
+ const dx = (x - xPrev) / dt;
366
+ const edx = smooth(alpha(dt, f.dCutoff), dx, dxPrev);
367
+ const a = alpha(dt, f.minCutoff + f.beta * Math.abs(edx));
368
+ const xh = smooth(a, x, xPrev);
369
+ xPrev = xh; dxPrev = edx;
370
+ return xh;
371
+ };
372
+
373
+ const vec = (out, raw, dt) => {
374
+ const n = raw.length;
375
+ if (!primed) {
376
+ if (!Array.isArray(xPrev) || xPrev.length !== n) {
377
+ xPrev = new Array(n); dxPrev = new Array(n);
378
+ }
379
+ for (let i = 0; i < n; i++) { xPrev[i] = raw[i]; dxPrev[i] = 0; out[i] = raw[i]; }
380
+ primed = true;
381
+ return out;
382
+ }
383
+ for (let i = 0; i < n; i++) {
384
+ const dx = (raw[i] - xPrev[i]) / dt;
385
+ const edx = smooth(alpha(dt, f.dCutoff), dx, dxPrev[i]);
386
+ const a = alpha(dt, f.minCutoff + f.beta * Math.abs(edx));
387
+ const xh = smooth(a, raw[i], xPrev[i]);
388
+ xPrev[i] = xh; dxPrev[i] = edx;
389
+ out[i] = xh;
390
+ }
391
+ return out;
392
+ };
393
+
394
+ const f = (out, raw, dt) =>
395
+ (typeof out === 'number') ? scalar(out, raw) : vec(out, raw, dt);
396
+
397
+ f.minCutoff = (opts.minCutoff != null) ? opts.minCutoff : 1;
398
+ f.beta = (opts.beta != null) ? opts.beta : 0;
399
+ f.dCutoff = (opts.dCutoff != null) ? opts.dCutoff : 1;
400
+
401
+ /** Drop the carried state — call at a discontinuity (re-acquire / home). */
402
+ f.reset = () => { primed = false; return f; };
403
+
404
+ return f;
405
+ }
406
+
292
407
  /**
293
408
  * @file Matrix construction from geometric specs and partial decomposition.
294
409
  * @module tree/form
@@ -3021,6 +3136,392 @@ function createConstraint(kind, opts) {
3021
3136
  return new Constraint(kind, opts);
3022
3137
  }
3023
3138
 
3139
+ /**
3140
+ * @file Source-agnostic 6-DOF pose helm — integrates a live rate stream into a
3141
+ * { pos, rot } pose. Renderer- and transport-agnostic. Zero dependencies.
3142
+ * @module tree/helm
3143
+ * @license AGPL-3.0-only
3144
+ *
3145
+ * A helm is the rate-stream sibling of the Track family: where a Track produces
3146
+ * a pose from keyframes over time, a helm produces a pose from a live 6-DOF
3147
+ * delta stream (a SpaceNavigator, a tracked hand, an agent policy). It is NOT a
3148
+ * handle — a handle reports a `vec3` solved from a pointer ray; a helm reports a
3149
+ * pose (position + orientation).
3150
+ *
3151
+ * Family placement (peer of PoseTrack, NOT a Track subclass — no timeline):
3152
+ * feed(translation, rotation) push the latest raw device rate (input, as Track.add)
3153
+ * step(out, dt, basis) integrate the rate by dt into the pose (BRIDGE-DRIVEN,
3154
+ * as Track.tick) — writes the new pose into out
3155
+ * eval(out) read the current { pos, rot } (zero-alloc, as Track.eval)
3156
+ * home(pose?) re-home the integrated pose (NOT reset — no keyframes to clear)
3157
+ *
3158
+ * Value layer (opt-in, §10): a per-helm `fullScale` keeps the read-outs honest
3159
+ * across input scales; an opt-in `filter` (oneEuro) conditions the fed rates
3160
+ * before the deadzone inside step; `poseDelta` differences two absolute poses
3161
+ * into a feedable rate. With `filter` null and the default `fullScale`, the
3162
+ * clean rate-native path is unchanged.
3163
+ *
3164
+ * Quaternion algebra is provided by quat.js. Out-first throughout; no allocation
3165
+ * in feed/step/eval. Storage convention (matches the rest of the core): vec3 and
3166
+ * quat state are plain number[] (f64) — the same shape as track.js keyframes and
3167
+ * the frozen basis-vector constants.
3168
+ *
3169
+ * ── Frame discipline (`from` / `basis`) ──────────────────────────────────────
3170
+ * The space the rates are interpreted in is named by `from` (WORLD | EYE | mat4),
3171
+ * a declaration the BRIDGE reads — the core never learns about a camera. The
3172
+ * resolved orientation arrives per-step as `basis`: an eye→world mat4 (or null
3173
+ * for WORLD ≡ the identity basis). Both the linear and angular rates are rotated
3174
+ * through that basis, then the quaternion is composed world-frame. This is the
3175
+ * single code path that covers both manipulation conventions:
3176
+ *
3177
+ * - camera body-fly — basis is the driven camera's own eye matrix (which this
3178
+ * helm produced last frame ⇒ equals the integrated `q`,
3179
+ * zero staleness); rotating a body delta through `q` then
3180
+ * world-composing is algebraically a body-frame compose.
3181
+ * - screen-relative — basis is the *viewing* camera's eye matrix (an external
3182
+ * frame); a push moves the target relative to the screen.
3183
+ *
3184
+ * Per-channel `from` ({ translation, rotation }) is a deferred, non-breaking
3185
+ * extension (one basis suffices today — pose-helm-design.md §8.3).
3186
+ */
3187
+
3188
+
3189
+ // =========================================================================
3190
+ // Module-level scratch — shared across helm instances (non-reentrant; step
3191
+ // is synchronous and called at most once per helm per frame).
3192
+ // =========================================================================
3193
+
3194
+ const _dq = [0, 0, 0, 1];
3195
+
3196
+ // Scratch for the optional input filter (helm.filter). The fed rates are packed
3197
+ // into one 6-vector, conditioned, and unpacked to lin/ang triples before the
3198
+ // deadzone — so a single filter carries one state per helm. Shared across
3199
+ // instances (step is synchronous, at most once per helm per frame).
3200
+ const _f6raw = [0, 0, 0, 0, 0, 0];
3201
+ const _f6out = [0, 0, 0, 0, 0, 0];
3202
+ const _fLin = [0, 0, 0];
3203
+ const _fAng = [0, 0, 0];
3204
+
3205
+ /**
3206
+ * Default full-deflection input magnitude — the fed rate a read-out shows as
3207
+ * full (the SpaceNavigator's saturated lane). Private to core: a transport on a
3208
+ * different raw scale sets helm.fullScale and never reads this, so there is no
3209
+ * public HELM_FULL export. Display-only; integration uses profile.sens directly.
3210
+ */
3211
+ const HELM_FULL_SCALE = 500;
3212
+
3213
+ // Deadzone gate — |v| > dz keeps v, else 0. Module-level so step allocates no
3214
+ // closure. Strictly-greater matches the e7 reference (rest reads exact 0).
3215
+ const _dz = (v, dz) => (v > dz || v < -dz) ? v : 0;
3216
+
3217
+ /**
3218
+ * Build the canonical default profile. SpaceNavigator-tuned lane permutation
3219
+ * and the eye-frame manipulation feel found in the e7 experiments; it is the
3220
+ * per-app / per-device mapping layer and is meant to be overwritten wholesale
3221
+ * (`helm.profile = { … }`) for a different transport.
3222
+ *
3223
+ * sign — per-app direction (camera-fly vs object-grab invert; flip live).
3224
+ * sens — per-axis sensitivity (tame roll without touching the rest).
3225
+ * lane — input-channel permutation: which fed channel drives this DOF.
3226
+ * T* lanes index the translation triple; R* lanes the rotation triple.
3227
+ */
3228
+ function _defaultProfile() {
3229
+ return {
3230
+ Tx: { sign: 1, sens: 0.30, lane: 0 },
3231
+ Ty: { sign: 1, sens: 0.30, lane: 2 },
3232
+ Tz: { sign: -1, sens: 0.30, lane: 1 },
3233
+ Rp: { sign: -1, sens: 0.0025, lane: 0 },
3234
+ Ry: { sign: -1, sens: 0.0025, lane: 2 },
3235
+ Rr: { sign: 1, sens: 0.0018, lane: 1 },
3236
+ };
3237
+ }
3238
+
3239
+ /** Channel order — readout / iteration convenience (Tx Ty Tz Rp Ry Rr). */
3240
+ const HELM_CHANNELS = Object.freeze(['Tx', 'Ty', 'Tz', 'Rp', 'Ry', 'Rr']);
3241
+
3242
+ // =========================================================================
3243
+ // PoseHelm
3244
+ // =========================================================================
3245
+
3246
+ /**
3247
+ * Source-agnostic 6-DOF pose producer. Holds a profile + the integrated pose;
3248
+ * no timeline (no keyframes, no play/seek/loop).
3249
+ */
3250
+ class PoseHelm {
3251
+ constructor() {
3252
+ /**
3253
+ * DOF mapping table. Six channels, each `{ sign, sens, lane }`.
3254
+ * Public and mutable — set wholesale to re-map a device or app.
3255
+ * @type {{ Tx:Object, Ty:Object, Tz:Object, Rp:Object, Ry:Object, Rr:Object }}
3256
+ */
3257
+ this.profile = _defaultProfile();
3258
+
3259
+ /**
3260
+ * Rest-drift floor. A fed rate with |value| ≤ deadzone reads as 0. Devices
3261
+ * that rest at exact 0 need only a small floor. @type {number}
3262
+ */
3263
+ this.deadzone = 8;
3264
+
3265
+ /**
3266
+ * Full-deflection input magnitude for the read-outs (gizmo overlay + panel
3267
+ * meters): a fed rate of |fullScale| reads as a full bar / arrow. Set by a
3268
+ * transport whose saturated rate differs from the default (a gamepad stick
3269
+ * that saturates at 1 sets fullScale = 1). Display-only — integration uses
3270
+ * profile.sens directly, so this never affects flight. @type {number}
3271
+ */
3272
+ this.fullScale = HELM_FULL_SCALE;
3273
+
3274
+ /**
3275
+ * Optional input conditioner applied to the fed rates BEFORE the deadzone,
3276
+ * inside step — a oneEuro filter, or any f(out, raw, dt) carrying function
3277
+ * with a reset(). null (the default) is the clean rate-native path: the
3278
+ * filter branch is skipped, so a clean device pays nothing. Set for noisy /
3279
+ * absolute sources; home() resets it. @type {?Function}
3280
+ */
3281
+ this.filter = null;
3282
+
3283
+ /**
3284
+ * The space fed rates are interpreted in: a space constant (WORLD | EYE) or
3285
+ * a mat4 frame. Declarative only — the bridge reads this to resolve the
3286
+ * per-step `basis`; the core stays camera-agnostic. @type {string|ArrayLike<number>}
3287
+ */
3288
+ this.from = 'EYE';
3289
+
3290
+ // Latest fed raw rates (refreshed by feed; persist until the next feed —
3291
+ // the transport is responsible for feeding 0 when motion should stop).
3292
+ this._lin = [0, 0, 0];
3293
+ this._ang = [0, 0, 0];
3294
+
3295
+ // Integrated pose — the source of truth.
3296
+ this._pos = [0, 0, 0];
3297
+ this._q = [0, 0, 0, 1];
3298
+ }
3299
+
3300
+ /**
3301
+ * Push the latest raw 6-DOF device rate. Either argument may be omitted to
3302
+ * leave that half unchanged (devices report the two halves on separate
3303
+ * frames). The value persists until the next feed. Raw device units — the
3304
+ * profile's `sens` does the scaling, so the same feed() suits any transport.
3305
+ *
3306
+ * @param {ArrayLike<number>} [translation] [tx, ty, tz] raw lane rates.
3307
+ * @param {ArrayLike<number>} [rotation] [rx, ry, rz] raw lane rates.
3308
+ * @returns {PoseHelm} this
3309
+ */
3310
+ feed(translation, rotation) {
3311
+ if (translation) {
3312
+ this._lin[0] = translation[0] || 0;
3313
+ this._lin[1] = translation[1] || 0;
3314
+ this._lin[2] = translation[2] || 0;
3315
+ }
3316
+ if (rotation) {
3317
+ this._ang[0] = rotation[0] || 0;
3318
+ this._ang[1] = rotation[1] || 0;
3319
+ this._ang[2] = rotation[2] || 0;
3320
+ }
3321
+ return this;
3322
+ }
3323
+
3324
+ /**
3325
+ * Integrate the latest fed rate by `dt` seconds into the pose, then write the
3326
+ * pose into `out`. Bridge-driven (the player calls it each frame; a sketch
3327
+ * never does, as it never calls track.tick()).
3328
+ *
3329
+ * `basis` is the resolved `from` frame: an eye→world column-major mat4
3330
+ * (16-element ArrayLike), or null/omitted for WORLD (the identity basis). Its
3331
+ * columns supply the right/up/back axes; forward is −col2. Both the linear and
3332
+ * angular rates are rotated through it, then `q` is composed world-frame.
3333
+ *
3334
+ * Zero-allocation. `out` is `{ pos:number[3], rot:number[4] }`; omit it for a
3335
+ * fresh object.
3336
+ *
3337
+ * @param {{ pos:number[], rot:number[] }} [out]
3338
+ * @param {number} dt Elapsed time in seconds.
3339
+ * @param {ArrayLike<number>} [basis] Eye→world mat4, or null for WORLD.
3340
+ * @returns {{ pos:number[], rot:number[] }} out
3341
+ */
3342
+ step(out, dt, basis) {
3343
+ const p = this.profile, dz = this.deadzone;
3344
+ let lin = this._lin, ang = this._ang;
3345
+
3346
+ // Value layer — condition the fed rates BEFORE the deadzone (the filter
3347
+ // narrows the jitter band; the deadzone then gates the residual to exact
3348
+ // zero). The filter carries state, so it is ticked exactly once per step;
3349
+ // activity() reads the raw fed rate and is left unfiltered (the read-out
3350
+ // shows device input, normalized by fullScale). Skipped when filter is null.
3351
+ const filter = this.filter;
3352
+ if (filter) {
3353
+ _f6raw[0] = lin[0]; _f6raw[1] = lin[1]; _f6raw[2] = lin[2];
3354
+ _f6raw[3] = ang[0]; _f6raw[4] = ang[1]; _f6raw[5] = ang[2];
3355
+ filter(_f6out, _f6raw, dt);
3356
+ _fLin[0] = _f6out[0]; _fLin[1] = _f6out[1]; _fLin[2] = _f6out[2];
3357
+ _fAng[0] = _f6out[3]; _fAng[1] = _f6out[4]; _fAng[2] = _f6out[5];
3358
+ lin = _fLin; ang = _fAng;
3359
+ }
3360
+
3361
+ // Basis axes (right / up / forward). No basis ⇒ the identity eye matrix:
3362
+ // right +X, up +Y, forward −Z. Forward is −col2 (an eye→world matrix stores
3363
+ // the camera's BACK in col2), and the identity's col2 is +Z, so forward is
3364
+ // −Z — making step(out, dt, null) identical to step(out, dt, IDENTITY_MAT4),
3365
+ // as the `from` contract ("null ≡ the identity basis") promises.
3366
+ let rX = 1, rY = 0, rZ = 0;
3367
+ let uX = 0, uY = 1, uZ = 0;
3368
+ let fX = 0, fY = 0, fZ = -1;
3369
+ if (basis) {
3370
+ rX = basis[0]; rY = basis[1]; rZ = basis[2];
3371
+ uX = basis[4]; uY = basis[5]; uZ = basis[6];
3372
+ fX = -basis[8]; fY = -basis[9]; fZ = -basis[10];
3373
+ }
3374
+
3375
+ // Angular: three rates → one delta quat, rotated into world, world-composed.
3376
+ const wx = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
3377
+ const wy = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
3378
+ const wz = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
3379
+ const w = Math.sqrt(wx * wx + wy * wy + wz * wz);
3380
+ if (w > 0) {
3381
+ // Axis in world = basis · (wx,wy,wz); its length is w (basis orthonormal),
3382
+ // so the rotation angle is w·dt. qFromAxisAngle renormalizes the axis.
3383
+ const ax = wx * rX + wy * uX + wz * fX;
3384
+ const ay = wx * rY + wy * uY + wz * fY;
3385
+ const az = wx * rZ + wy * uZ + wz * fZ;
3386
+ qFromAxisAngle(_dq, ax, ay, az, w * dt);
3387
+ qMul(this._q, _dq, this._q); // delta already in world → world-frame compose
3388
+ qNormalize(this._q);
3389
+ }
3390
+
3391
+ // Linear: three rates rotated into world, integrated by dt.
3392
+ const tx = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
3393
+ const ty = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
3394
+ const tz = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
3395
+ this._pos[0] += (tx * rX + ty * uX + tz * fX) * dt;
3396
+ this._pos[1] += (tx * rY + ty * uY + tz * fY) * dt;
3397
+ this._pos[2] += (tx * rZ + ty * uZ + tz * fZ) * dt;
3398
+
3399
+ return this.eval(out);
3400
+ }
3401
+
3402
+ /**
3403
+ * Read the current pose into `out` (zero-alloc). The shared output contract
3404
+ * that feeds applyPose — mirrors Track.eval.
3405
+ *
3406
+ * @param {{ pos:number[], rot:number[] }} [out]
3407
+ * @returns {{ pos:number[], rot:number[] }} out
3408
+ */
3409
+ eval(out) {
3410
+ out = out || { pos: [0, 0, 0], rot: [0, 0, 0, 1] };
3411
+ out.pos[0] = this._pos[0]; out.pos[1] = this._pos[1]; out.pos[2] = this._pos[2];
3412
+ out.rot[0] = this._q[0]; out.rot[1] = this._q[1];
3413
+ out.rot[2] = this._q[2]; out.rot[3] = this._q[3];
3414
+ return out;
3415
+ }
3416
+
3417
+ /**
3418
+ * Write the six effective channel rates (post deadzone·sign·sens) into `out6`,
3419
+ * in channel order [Tx, Ty, Tz, Rp, Ry, Rr]. The gizmo reads this to light the
3420
+ * DOFs being driven; readouts can show signed magnitudes. Zero-alloc.
3421
+ *
3422
+ * @param {number[]} out6 6-element destination.
3423
+ * @returns {number[]} out6
3424
+ */
3425
+ activity(out6) {
3426
+ const p = this.profile, dz = this.deadzone, lin = this._lin, ang = this._ang;
3427
+ out6[0] = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
3428
+ out6[1] = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
3429
+ out6[2] = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
3430
+ out6[3] = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
3431
+ out6[4] = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
3432
+ out6[5] = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
3433
+ return out6;
3434
+ }
3435
+
3436
+ /**
3437
+ * Re-home the integrated pose. Sets position + orientation to `pose` (or the
3438
+ * identity pose when omitted) and clears any pending rate so the helm rests at
3439
+ * a clean, known state. NOT a reset — there are no keyframes to clear.
3440
+ *
3441
+ * @param {{ pos?:ArrayLike<number>, rot?:ArrayLike<number> }} [pose]
3442
+ * @returns {PoseHelm} this
3443
+ */
3444
+ home(pose) {
3445
+ if (pose && pose.pos) {
3446
+ this._pos[0] = pose.pos[0]; this._pos[1] = pose.pos[1]; this._pos[2] = pose.pos[2];
3447
+ } else {
3448
+ this._pos[0] = 0; this._pos[1] = 0; this._pos[2] = 0;
3449
+ }
3450
+ if (pose && pose.rot) {
3451
+ this._q[0] = pose.rot[0]; this._q[1] = pose.rot[1];
3452
+ this._q[2] = pose.rot[2]; this._q[3] = pose.rot[3];
3453
+ } else {
3454
+ this._q[0] = 0; this._q[1] = 0; this._q[2] = 0; this._q[3] = 1;
3455
+ }
3456
+ this._lin[0] = this._lin[1] = this._lin[2] = 0;
3457
+ this._ang[0] = this._ang[1] = this._ang[2] = 0;
3458
+ if (this.filter) this.filter.reset(); // drop filter state at the discontinuity
3459
+ return this;
3460
+ }
3461
+ }
3462
+
3463
+ // =========================================================================
3464
+ // poseDelta — absolute pose → 6-DOF rate
3465
+ // =========================================================================
3466
+
3467
+ /**
3468
+ * Difference two consecutive absolute poses into a 6-DOF rate the helm can be
3469
+ * fed — the bridge for absolute-pose transports (gesture / landmark / marker /
3470
+ * IMU) into feed(lin, ang). Out-first, zero-allocation.
3471
+ *
3472
+ * Linear rate is (cur.pos − prev.pos) / dt. Angular rate is the world-frame
3473
+ * angular velocity ω carrying prev.rot onto cur.rot over dt: the relative
3474
+ * rotation r = cur · conj(prev) (so cur = r · prev, the world-frame compose the
3475
+ * step integrates), read as axis · angle / dt.
3476
+ *
3477
+ * The double-cover guard is the whole reason to ship this rather than inline it:
3478
+ * a quaternion and its negation are the same orientation, so when
3479
+ * dot(prev.rot, cur.rot) < 0 the two samples sit on opposite hemispheres and a
3480
+ * naive difference takes the long way round — the angular rate spikes at the
3481
+ * crossing. Flipping cur into prev's hemisphere first keeps r the shortest arc.
3482
+ *
3483
+ * Fed through a helm with an identity profile in WORLD, the integrated pose
3484
+ * retraces the source (the round-trip e9 asserts). A 1:1, non-integrated
3485
+ * consumer skips the helm and applyPoses the absolute pose directly.
3486
+ *
3487
+ * @param {{ lin:number[], ang:number[] }} [out] Destination; omit for a fresh one.
3488
+ * @param {{ pos:ArrayLike<number>, rot:ArrayLike<number> }} prev Previous pose.
3489
+ * @param {{ pos:ArrayLike<number>, rot:ArrayLike<number> }} cur Current pose.
3490
+ * @param {number} dt Elapsed seconds between the two samples.
3491
+ * @returns {{ lin:number[], ang:number[] }} out
3492
+ */
3493
+ function poseDelta(out, prev, cur, dt) {
3494
+ out = out || { lin: [0, 0, 0], ang: [0, 0, 0] };
3495
+ const inv = 1 / dt;
3496
+
3497
+ out.lin[0] = (cur.pos[0] - prev.pos[0]) * inv;
3498
+ out.lin[1] = (cur.pos[1] - prev.pos[1]) * inv;
3499
+ out.lin[2] = (cur.pos[2] - prev.pos[2]) * inv;
3500
+
3501
+ const px = prev.rot[0], py = prev.rot[1], pz = prev.rot[2], pw = prev.rot[3];
3502
+ let cx = cur.rot[0], cy = cur.rot[1], cz = cur.rot[2], cw = cur.rot[3];
3503
+
3504
+ // Double-cover guard: bring cur into prev's hemisphere so r is the short arc.
3505
+ if (px * cx + py * cy + pz * cz + pw * cw < 0) { cx = -cx; cy = -cy; cz = -cz; cw = -cw; }
3506
+
3507
+ // Relative rotation r = cur · conj(prev), conj(prev) = (−px, −py, −pz, pw).
3508
+ const rx = -cw * px + cx * pw - cy * pz + cz * py;
3509
+ const ry = -cw * py + cx * pz + cy * pw - cz * px;
3510
+ const rz = -cw * pz - cx * py + cy * px + cz * pw;
3511
+ const rw = cw * pw + cx * px + cy * py + cz * pz; // = dot(prev, cur) ≥ 0 → angle ≤ π
3512
+
3513
+ // Axis · angle of r → ω = axis · angle / dt. Below the threshold the rotation
3514
+ // is negligible (no well-defined axis) and the angular rate is exact zero.
3515
+ const sinHalf = Math.sqrt(rx * rx + ry * ry + rz * rz);
3516
+ if (sinHalf < 1e-8) {
3517
+ out.ang[0] = 0; out.ang[1] = 0; out.ang[2] = 0;
3518
+ } else {
3519
+ const k = (2 * Math.atan2(sinHalf, rw)) * inv / sinHalf;
3520
+ out.ang[0] = rx * k; out.ang[1] = ry * k; out.ang[2] = rz * k;
3521
+ }
3522
+ return out;
3523
+ }
3524
+
3024
3525
  /**
3025
3526
  * @file Frustum planes and visibility tests — zero allocations.
3026
3527
  * @module tree/visibility
@@ -3180,5 +3681,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
3180
3681
  return allIn ? VISIBLE : SEMIVISIBLE;
3181
3682
  }
3182
3683
 
3183
- export { AXIS, CameraTrack, Constraint, DIAL, DIRECTION, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, POINT, PoseTrack, SCREEN, SEMIVISIBLE, SPHERE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, azElFromDir, boxVisibility, createConstraint, dirFromAzEl, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Bias, mat4Eye, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Persp, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, mat4View, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qConjugate, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qFromUnitVectors, qMul, qNegate, qNlerp, qNormalize, qRotateVec3, qSet, qSlerp, qToAxisAngle, qToMat4, rayClosestPointOnAxis, rayPlane, raySphere, sphereVisibility, transformToMat4 };
3684
+ export { AXIS, CameraTrack, Constraint, DIAL, DIRECTION, EYE, HELM_CHANNELS, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, POINT, PoseHelm, PoseTrack, SCREEN, SELF, SEMIVISIBLE, SPHERE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, azElFromDir, boxVisibility, createConstraint, dirFromAzEl, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Bias, mat4Eye, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Persp, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, mat4View, oneEuro, pixelRatio, pointVisibility, poseDelta, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qConjugate, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qFromUnitVectors, qMul, qNegate, qNlerp, qNormalize, qRotateVec3, qSet, qSlerp, qToAxisAngle, qToMat4, rayClosestPointOnAxis, rayPlane, raySphere, sphereVisibility, transformToMat4 };
3184
3685
  //# sourceMappingURL=index.js.map