@nakednous/tree 0.0.26 → 0.0.28

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
@@ -18,22 +18,24 @@ import * as tree from '@nakednous/tree'
18
18
 
19
19
  ## Architecture
20
20
 
21
- `@nakednous/tree` is the bottom layer of a three-package stack. It knows nothing about renderers, the DOM, or p5 — it operates on plain arrays and `Float32Array` buffers throughout.
21
+ `@nakednous/tree` is the bottom layer of a stack. It knows nothing about renderers, the DOM, or p5 — it operates on plain arrays and `Float32Array` buffers throughout.
22
22
 
23
23
  ```
24
24
  application
25
25
 
26
26
 
27
- p5.tree.js bridge: wires tree + ui into p5.js v2
27
+ twgl.tree · p5.tree · webgpu.tree bridges: draw, the GPU ceremony, a framework adapter
28
+
29
+ ├── @nakednous/host ← DOM transport: pointer, view, players, handles, devices, labels, orbit
28
30
 
29
31
  ├── @nakednous/ui ← DOM param panels, transport controls
30
32
 
31
- └── @nakednous/tree ← this package: math, spaces, animation, visibility
33
+ └── @nakednous/tree ← this package: math, spaces, animation, visibility, gizmo geometry
32
34
  ```
33
35
 
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.
36
+ The dependency direction is strict: `@nakednous/tree` never imports from the host, the bridges, or the DOM layer; `host` and `ui` depend on `tree` only; a bridge depends on `tree` and `host`, never on another bridge. `@nakednous/*` never renders — rendering lives in the `*.tree` bridges. This is what lets the same `PoseTrack` that drives a camera path also animate any object — headless, server-side, or in a future renderer — and the same gizmo arrays draw through twgl, p5, or WebGPU.
35
37
 
36
- Source is organised into six focused modules:
38
+ Source is organised into focused modules:
37
39
 
38
40
  ```
39
41
  form.js — you have specs, you want a matrix
@@ -41,7 +43,11 @@ query.js — you have a matrix, you want information
41
43
  quat.js — quaternion algebra and mat4/mat3 conversions
42
44
  track.js — spline math and keyframe animation state machines
43
45
  helm.js — 6-DOF rate-stream integrator — the Track family's live-input sibling
46
+ filter.js — input conditioning: the 1€ filter + absolute→rate differencing
44
47
  handle.js — constraint solver + ray primitives for interactive manipulators
48
+ visibility.js — frustum planes and visibility tests
49
+ camera.js — camera state (the CameraTrack keyframe shape) ↔ matrices, planes, and orbit edits
50
+ gizmo.js — line generators in twgl's arrays shape: axes, grid, cross, bulls-eye, ring, frustum, hermite, path, helm rig, locus, pane
45
51
  ```
46
52
 
47
53
  ---
@@ -104,7 +110,7 @@ track.add({ pos:[300,0,0] }) // auto tangents
104
110
  ```js
105
111
  rot: [x,y,z,w] // raw quaternion
106
112
  rot: { axis:[x,y,z], angle } // axis-angle
107
- rot: { dir:[x,y,z], up?:[x,y,z] } // look direction (−Z forward)
113
+ rot: { dir:[x,y,z], up?:[x,y,z] } // look direction (−Z forward); up re-seeded when ∥ dir
108
114
  rot: { euler:[rx,ry,rz], order?:'YXZ' } // intrinsic Euler angles (radians)
109
115
  // orders: YXZ (default), XYZ, ZYX,
110
116
  // ZXY, XZY, YZX
@@ -339,7 +345,7 @@ helm.profile = {
339
345
  **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):
340
346
 
341
347
  ```
342
- WORLD world axes — the identity basis (step's basis is null)
348
+ WORLD the world-aligned eye frame — the identity basis (forward −Z; step's basis is null)
343
349
  EYE a viewing camera's frame — screen-relative (default)
344
350
  SELF the helm's OWN evolving pose — body-relative
345
351
  <mat4> an explicit fixed frame
@@ -351,14 +357,55 @@ SELF the helm's OWN evolving pose — body-relative
351
357
 
352
358
  ```js
353
359
  helm.deadzone = 8 // rest-drift floor — |rate| ≤ deadzone reads as 0
360
+ helm.filter = oneEuro({ minCutoff: 1, beta: 0.5 }) // optional input conditioner (filter → deadzone)
361
+ helm.fullScale = 500 // raw full-deflection magnitude a read-out divides by
354
362
  helm.activity(out6) // six effective rates (post deadzone·sign·sens), channel order
355
- helm.home([pose]) // re-home pos + rot (NOT reset — no keyframes); clears pending rate
363
+ helm.home([pose]) // re-home pos + rot (NOT reset — no keyframes); clears pending rate; resets filter
356
364
  ```
357
365
 
366
+ `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.
367
+
358
368
  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.
359
369
 
360
370
  ---
361
371
 
372
+ ### Input conditioning — `oneEuro` · `poseDelta`
373
+
374
+ Two helpers for the rate stream a helm feeds on — flat, out-first, zero-alloc (`filter.js`).
375
+
376
+ **`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:
377
+
378
+ ```js
379
+ import { oneEuro } from '@nakednous/tree'
380
+
381
+ const f = oneEuro({ minCutoff: 1, beta: 0.5 }) // params live-mutable: f.minCutoff, f.beta
382
+
383
+ // scalar form — returns the filtered number
384
+ const y = f(rawScalar, dt)
385
+
386
+ // vec form — out-first, zero-alloc after warm-up
387
+ const out = [0, 0, 0]
388
+ f(out, rawVec3, dt)
389
+
390
+ f.reset() // drop state — next call re-seeds
391
+ ```
392
+
393
+ 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).
394
+
395
+ **`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.
396
+
397
+ ```js
398
+ import { poseDelta } from '@nakednous/tree'
399
+
400
+ const rate = { lin: [0, 0, 0], ang: [0, 0, 0] }
401
+ poseDelta(rate, prevPose, curPose, dt) // prev / cur: { pos:[x,y,z], rot:[x,y,z,w] }
402
+ helm.feed(rate.lin, rate.ang)
403
+ ```
404
+
405
+ 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.
406
+
407
+ ---
408
+
362
409
  ### Coordinate-space mapping
363
410
 
364
411
  `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.
@@ -434,6 +481,45 @@ Three-state result: `VISIBLE` (fully inside), `SEMIVISIBLE` (intersecting), `INV
434
481
 
435
482
  ---
436
483
 
484
+ ### Camera state
485
+
486
+ The camera is plain data — the `CameraTrack` keyframe shape — with pure functions between it and the matrices a draw uploads (`camera.js`). There is no camera object: a track evaluates into the state, a pose drives it, an orbit gesture edits it, and a renderer installs the matrices built from it.
487
+
488
+ ```js
489
+ import { createCamera, cameraView, cameraEye, cameraProj, cameraPlanes, WEBGL } from '@nakednous/tree'
490
+
491
+ const cam = createCamera({ eye: [0, 0, 500], center: [0, 0, 0], fov: Math.PI / 3, near: 0.1, far: 1000 })
492
+ // { eye, center, up, fov | halfHeight, near, far } — fov xor halfHeight (perspective xor orthographic)
493
+
494
+ // per-frame — zero allocation; aspect belongs to the viewport, never to the state
495
+ cameraView(V, cam) // world → eye
496
+ cameraEye(E, cam) // eye → world
497
+ cameraProj(P, cam, width / height, WEBGL) // mat4Persp from fov or mat4Ortho from halfHeight; null when both are null
498
+ cameraPlanes(planes, cam, width / height) // the six frustum planes — visibility straight from the state
499
+ track.eval(cam) // a CameraTrack writes the state directly
500
+ ```
501
+
502
+ Decomposers read the state back:
503
+
504
+ ```js
505
+ cameraFromMat4(cam, E, P, WEBGL) // eye, up, forward from E; the lens from P; the gaze distance |center − eye| is kept
506
+ cameraFromPose(cam, pose) // { pos, rot } → lookat at constant gaze distance; the lens untouched
507
+ cameraToPose(pose, cam) // lookat → { pos, rot } — the rotation of cameraEye, a helm's seed
508
+ cameraCopy(out, cam) // one state into another
509
+ ```
510
+
511
+ Edits are in place and chainable — the arithmetic behind an orbit gesture, callable from a script just the same:
512
+
513
+ ```js
514
+ cameraOrbit(cam, dAz, dEl, { maxEl }) // azimuth about the up hint, elevation clamped short of the pole; never rolls
515
+ cameraDolly(cam, factor, { min, max }) // scales the gaze distance — or halfHeight under orthographic
516
+ cameraPan(cam, dx, dy) // along the eye's right and up, world units (pixelRatio converts pixels)
517
+ ```
518
+
519
+ Every function that needs the camera frame derives it through `mat4Eye`, so planes, poses and edits agree with `cameraEye` exactly — including the up re-seed when the view direction is parallel to the hint.
520
+
521
+ ---
522
+
437
523
  ### Manipulator constraints
438
524
 
439
525
  `handle.js` is the renderer-agnostic core of an interactive manipulator: ray-primitive intersections, az/el utilities, and a `Constraint` state machine. The `p5.tree` bridge wraps these into a draggable handle; this package supplies the math and the **contract** that makes the handle extensible.
@@ -441,6 +527,7 @@ Three-state result: `VISIBLE` (fully inside), `SEMIVISIBLE` (intersecting), `INV
441
527
  ```js
442
528
  import { createConstraint, SPHERE, PLANE, AXIS, DIAL, POINT, DIRECTION,
443
529
  raySphere, rayPlane, rayClosestPointOnAxis,
530
+ rayHitSphere, rayHitCapsule, rayHitRing,
444
531
  dirFromAzEl, azElFromDir } from '@nakednous/tree'
445
532
 
446
533
  const c = createConstraint(SPHERE, { radius: 1 }) // or PLANE / AXIS / DIAL
@@ -451,7 +538,28 @@ c.value(out, DIRECTION) // write the reported value into out(3)
451
538
 
452
539
  `SPHERE` stores a unit direction (gimbal-free); `PLANE` / `AXIS` store a constrained point; `DIAL` stores an accumulated angle θ (multi-turn winding preserved). `value` reports a `DIRECTION` (unit) or a `POINT` per kind. `aim(ax,ay,az[, zx,zy,zz])` re-aims the constraint basis in the working space — `PLANE` takes a new normal (point re-projected), `AXIS` a new direction (`t` preserved), `DIAL` a new plane normal plus optional θ=0 reference (θ preserved) — the seam the `p5.tree` bridge's deferred `from` frame drives. Ray primitives are out-first and assume a unit ray direction; `rayPlane` returns `Infinity` when the ray is parallel.
453
540
 
454
- **Constraint contract (extension seam).** A constraint is any object exposing `kind`, `solve(ox,oy,oz, dx,dy,dz)`, `value(out, report)`, `seed(x,y,z)`, and optionally `scalar()` / `azEl(out2)` / `aim(ax,ay,az[, zx,zy,zz])`. The handle controller drives any conforming constraint, so a new kind rotation, 6-DOF, or app-specificimplements this contract (portable, draw-free) plus a bridge-side locus draw, rather than forking the controller. The built-in `Constraint` is the reference implementation. Full design: [`handle-design.md`](./handle-design.md).
541
+ **Hit tests the analytic pick.** Beside the solve primitives, which always write a point, three tests write nothing and return the ray parameter `t` of the nearest hit with `t ≥ 0`, or `Infinity`: `rayHitSphere(o, d, c, r)`, `rayHitCapsule(o, d, a, b, r)` (the segment `a→b` swept by `r`) and `rayHitRing(o, d, c, u, R, r, detail = 32)` (the circle of radius `R` about `c` in the plane `u`, swept by tube radius `r`, as a capsule chain of `detail` links chordal error `R · (1 − cos(π / detail))`, never degenerate edge-on). A ray starting inside hits at its exit, so a press from inside a proxy still grabs. These are what a host's controller picks with instead of a tagged render pass: unproject the pointer, convert the grab size to working units through `pixelRatio`, test every candidate, nearest `t` wins.
542
+
543
+ **Constraint contract (extension seam).** A constraint is any object exposing `kind`, `solve(ox,oy,oz, dx,dy,dz)`, `value(out, report)`, `seed(x,y,z)`, and optionally `scalar()` / `azEl(out2)` / `aim(ax,ay,az[, zx,zy,zz])` / `proxy(ox,oy,oz, dx,dy,dz, radius)` — the analytic pick: `t` or `Infinity` for a ray against the grab proxy of `radius` working units (built-in kinds: a sphere at the reported `POINT`; `DIAL`: the ring at the anchor with tube `radius`; a kind without one gets the sphere). The handle controller drives any conforming constraint, so a new kind — rotation, 6-DOF, or app-specific — implements this contract (portable, draw-free, its hit test included) plus a bridge-side locus draw, rather than forking the controller. The built-in `Constraint` is the reference implementation. Full design: [`handle-design.md`](./handle-design.md).
544
+
545
+ ---
546
+
547
+ ### Gizmo geometry
548
+
549
+ `gizmo.js` generates the vertices a gizmo is made of — renderer-free, into a caller-owned arrays object in twgl's `arrays` shape, line lists (and one triangle list) that `createBufferInfoFromArrays` uploads as they are and a WebGPU vertex buffer is filled from. Drawing, colour state, HUD mode, textures and text stay in the bridges.
550
+
551
+ ```js
552
+ import { createArrays, growArrays, capacityOf,
553
+ axesLines, gridLines, crossLines, bullsEyeLines, ringLines,
554
+ frustumLines, frustumCorners, hermiteLines,
555
+ pathLines, helmRigLines, locusLines, paneTris } from '@nakednous/tree'
556
+
557
+ const out = createArrays(64, { color: true }) // { position, color, count }, the one allocating call
558
+ let n = axesLines(out, { size: 100 }) // → the vertex count needed; writes min(n, capacity)
559
+ if (n > capacityOf(out)) { growArrays(out, n); axesLines(out, { size: 100 }) }
560
+ ```
561
+
562
+ Every generator is snprintf-style: it returns the count it needs, writes what fits and sets `out.count`, and states its count formula so a caller can pre-size exactly. With an `out.color` array, `axesLines` and `helmRigLines` write the semantic palette (`COLOR_X` · `COLOR_Y` · `COLOR_Z`, `COLOR_DIM` alpha for a dimmed stroke) and every other generator writes `opts.color`. The bit namespaces (`X` … `LABELS`, `NEAR` … `APEX`, `PATH` … `HANDLES`, `TRANSLATE` · `ROTATE`, `HANDLE` … `RING`) are gizmo-local. `frustumCorners` writes a camera's eight world-space corners (near face counter-clockwise from bottom-left, then far) from a camera state or a matrix-captured `{ mat4Eye, mat4Proj, ndcZMin }`; `pathLines` walks a track's own samplers; `helmRigLines` reads a helm's profile and activity; `locusLines` dispatches on a constraint's kind (or its own `locus(out, opts)`); `paneTris` is the textured quad with one upright uv orientation. Full design: [`gizmo-design.md`](./gizmo-design.md).
455
563
 
456
564
  ---
457
565
 
@@ -472,7 +580,7 @@ qToAxisAngle
472
580
 
473
581
  **Mat4 arithmetic** (`query.js`):
474
582
  ```
475
- mat4Mul mat4Invert mat4Transpose mat4MulPoint mat4MulDir
583
+ mat4Mul mat4Invert mat4MulPoint mat4MulDir
476
584
  mat3NormalFromMat4 mat4Location mat3Direction
477
585
  mat4PV mat4MV
478
586
  ```
@@ -482,8 +590,8 @@ mat4PV mat4MV
482
590
  **Matrix construction from specs** (`form.js`):
483
591
  ```
484
592
  mat4FromBasis — rigid frame from orthonormal basis + translation
485
- mat4View — view matrix (world→eye) from lookat params
486
- mat4Eye — eye matrix (eye→world) from lookat params
593
+ mat4View — view matrix (world→eye) from lookat params (up re-seeded when ∥ view direction)
594
+ mat4Eye — eye matrix (eye→world) from lookat params (same rule)
487
595
  mat4FromTRS — column-major mat4 from flat TRS scalars
488
596
  mat4FromTranslation — translation-only mat4
489
597
  mat4FromScale — scale-only mat4
@@ -510,6 +618,20 @@ projLeft projRight projTop projBottom
510
618
 
511
619
  **Pick matrix:** `mat4Pick(proj, px, py, vp)` — mutates a projection matrix in-place so that the pixel at `(px, py)` maps to the full NDC square, making a 1×1 FBO render contain exactly that pixel. Takes the same signed viewport `vp` as `mapLocation` — the y-convention is preserved automatically.
512
620
 
621
+ **Pointer ray:** `unproject(outO, outD, sx, sy, m, vp, ndcZMin)` — a screen point as a world ray: origin on the near plane, unit direction toward the far plane. Same bag and signed viewport as `mapLocation` (`mat4PVInv` filled by the caller); `null` when the bag has no inverse. The point-at-depth form stays `mapLocation(SCREEN → WORLD)` with a depth in `z`.
622
+
623
+ **Pointer hit:** `pointerHit(px, py, x, y, z, radius, m, vp, ndcZMin, shape = CIRCLE)` — is the pointer within `radius` px of the projected world point? `CIRCLE` (Euclidean) or `SQUARE` (Chebyshev), boundary inclusive; a point whose screen depth falls outside `[0, 1]` never hits.
624
+
625
+ **Pick-id codec:** `idToRgba(out, id)` packs a 24-bit id into `[r, g, b, 1]` normalised floats, R the low byte; `rgbaToId(r, g, b)` decodes the bytes of a readback. Id `0` is the background; ids run `1 … 2²⁴ − 1`.
626
+
627
+ **Camera state** (`camera.js`):
628
+ ```
629
+ createCamera cameraCopy
630
+ cameraView cameraEye cameraProj cameraPlanes
631
+ cameraFromMat4 cameraFromPose cameraToPose
632
+ cameraOrbit cameraDolly cameraPan
633
+ ```
634
+
513
635
  ---
514
636
 
515
637
  ### Constants
@@ -532,6 +654,9 @@ INVISIBLE, VISIBLE, SEMIVISIBLE
532
654
  SPHERE, PLANE, AXIS, DIAL
533
655
  POINT, DIRECTION
534
656
 
657
+ // Pointer-hit shapes
658
+ CIRCLE, SQUARE
659
+
535
660
  // Basis vectors (frozen)
536
661
  ORIGIN, i, j, k, _i, _j, _k
537
662
  ```
@@ -562,6 +687,19 @@ mapLocation(out, px, py, pz, WORLD, SCREEN,
562
687
 
563
688
  ---
564
689
 
690
+ ## Golden vectors
691
+
692
+ `golden/` holds one JSON fixture per source module — `{ args, out }` cases for every exported function and `{ call, args, expect }` transcripts for the stateful classes — generated from this core and committed with the repo (not shipped in the package). They are both the regression suite and the port contract: a port is conformant when it reproduces them within the stated tolerances (exact for ints and enums, `1e-6` for `f64` state, `1e-5` for `f32` matrices).
693
+
694
+ ```bash
695
+ npm run golden # regenerate every fixture from src/
696
+ npm test # assert src/ against golden/, and that every export has a fixture
697
+ ```
698
+
699
+ The fixture format is specified in `tools/golden.js`. A function without a fixture is not exported.
700
+
701
+ ---
702
+
565
703
  ## Relationship to `p5.tree`
566
704
 
567
705
  [p5.tree](https://github.com/VisualComputing/p5.tree) is the bridge layer. It reads live renderer state (camera matrices, viewport dimensions, NDC convention) and passes it to `@nakednous/tree` functions. It wires `PoseTrack` and `CameraTrack` to the p5 draw loop, exposes `createPoseTrack` / `createCameraTrack` / `getCamera`, and provides `createPanel` for transport and parameter UIs.