@nakednous/host 0.0.5 → 0.0.8

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
@@ -79,10 +79,11 @@ dependency first and an options object last, and registers with the context so
79
79
  | `host.handle(opts)` | `Handle` — a draggable core constraint (`SPHERE` · `PLANE` · `AXIS` · `DIAL` · `VIEW`, or a contract object); `update()`, `value(out, opts)`, snap, hover, cancel, `from` |
80
80
  | `host.router(handles, opts)` | `PointerRouter` — one shared pick over overlapping handles, nearest proxy wins; shared hover |
81
81
  | `host.cameraHelm(cam, opts)` · `host.poseHelm(opts)` | a core `PoseHelm` on the players: body-fly a camera state, or integrate into a pose in `WORLD` · `EYE` · `SELF` · a mat4 and `bind()` a target |
82
- | `host.poseTrack(opts)` · `host.cameraTrack(cam, opts)` | core tracks ticked while they play; `{ handles }` builds `TrackHandles`, one handle per keyframe |
82
+ | `host.poseTrack(opts)` · `host.cameraTrack(cam, opts)` | core tracks ticked while they play; `{ handles }` builds `TrackHandles`, one handle per keyframe. `add(depth)` — the transport panel's `+` — authors where the camera is looking: a pose track places the pose at the centre of the installed camera's frustum, `depth` `0`…`1` from the near plane to the far one, aimed along that camera (the view bag's matrices; never deduplicated), a camera track captures its camera state, a placement depth being nothing a camera keyframe could use. `remove()` — the transport panel's `−` — retracts the last keyframe, one per call; `remove(i)` is the core track's own |
83
83
  | `host.hid(opts)` · `host.gamepad(opts)` | rate streams — `lin[3]` / `ang[3]` in raw device units, `bind(helm)` feeds it each tick; `available`, `connected` |
84
84
  | `host.image(url)` · `host.video(opts)` · `host.raster(draw, w, h)` | texture sources — an `ImageBitmap`, a hidden `<video>` from a file or the camera with a `ready` promise, a Canvas2D drawing |
85
- | `host.model(url)` | an OBJ model as arrays — `{ position, normal?, texcoord?, indices }`, what twgl's `createBufferInfoFromArrays` takes; host's own parser (fan-triangulated faces, one vertex per distinct position / uv / normal triple), materials ignored |
85
+ | `host.mesh(url, opts)` | one mesh — the arrays shape `{ position, indices, normal?, tangent?, texcoord?, color?, joints?, weights? }` with its `bounds` `{ min, max, center, diag }` beside them — from an OBJ file, a one-part glTF 2.0 file (`.glb`, `.gltf`), or any format through `{ parse: (arrayBuffer) => arrays }`; what a bridge's buffer upload takes as it is, and `size / mesh.bounds.diag` fits any file to `size`. A file of several parts rejects, naming `model`. `{ format }` when the URL does not say. **`normals`**: left out, the file's normals are kept and a mesh without any gets smooth ones; `'smooth'` always recomputes them, summed per position, so a faceted file or a texture seam smooths too; `'flat'` always recomputes them on the flattened mesh (every triangle owning its vertices: more vertices, indices counting up); `false` computes nothing. The procedure, its limits and sources are tree's `mesh` module's; glTF viewers compute flat normals for a file without any — here that is `'flat'` |
86
+ | `host.model(url, opts)` | a model with its structure — `{ parts: [{ name, node, skin, mesh, targets, color }], nodes: { names, parents, rest }, skins: [{ name, joints, inverseBind }], clips: [{ name, duration, channels }] }` — what a rig, morph targets or animation need. A part's `mesh` is as `host.mesh` returns it, in the part's own space; `targets` are morph deltas; nodes come parents first with a rest pose, and with `skins` and `clips` feed tree's `clipSample` · `poseBlend` · `poseWorld` · `jointPalette` as they are. Same options; `'flat'` flattens each part's morph targets with its mesh. An OBJ file is one white part under one identity node. host's own parsers: no textures, cameras, sparse accessors or compressed geometry |
86
87
  | `host.labels` · `host.hasLabels` | the label layer, created on first access — `set(id, text, x, y, z, opts)` at a world anchor, `setScreen(id, text, sx, sy, opts)` in canvas px, `{ frame: true }` for a label that lives one frame, `tick()`; `hasLabels` probes without creating |
87
88
  | `host.orbit(cam, opts)` | the fall-through gesture — one pointer orbits, two pan and dolly, the wheel dollies; `damping` in seconds (default 0.2; 0 exact) makes the camera trail the gesture and complete it after release; `update(dt)` reports whether the camera moved; the vertical sense follows the bag's projection (y-up GL, y-flipped p5) |
88
89
  | `host.tick(dt)` | external-loop mode: tick the players; the caller flushes the pointer once its consumers ran |
@@ -91,7 +92,7 @@ dependency first and an options object last, and registers with the context so
91
92
  The module-level functions behind the methods are exported too (`createView`, `createPointer`,
92
93
  `createPlayers`, `createLoop`, `observeCanvas`, `Handle`, `PointerRouter`, `cameraHelm`,
93
94
  `poseHelm`, `helmBasis`, `poseTrack`, `cameraTrack`, `TrackHandles`, `createHid`,
94
- `createGamepad`, `decodeSpaceNavigator`, `loadImage`, `createVideo`, `raster`, `loadModel`, `createLabels`,
95
+ `createGamepad`, `decodeSpaceNavigator`, `loadImage`, `createVideo`, `raster`, `loadMesh`, `loadModel`, `createLabels`,
95
96
  `createOrbit`), so an adapter can compose them on its own context.
96
97
 
97
98
  ---
@@ -154,6 +155,15 @@ npm run build # rollup → dist/index.js
154
155
 
155
156
  ---
156
157
 
158
+ ## Acknowledgements
159
+
160
+ - [glTF 2.0](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) (Khronos Group) — the format `host/gltf` reads: the GLB container, accessors and buffer views, skins, morph targets, animations.
161
+ - Wavefront OBJ — the format `host/obj` reads.
162
+ - [three.js](https://threejs.org/) — the orbit's damping follows the shape of its `OrbitControls`, made time-based.
163
+ - [twgl](https://twgljs.org/) — the arrays shape a model's meshes arrive in.
164
+
165
+ Both parsers are host's own; no third-party code ships in the package.
166
+
157
167
  ## License
158
168
 
159
169
  AGPL-3.0-only
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { WEBGL, cameraView, cameraProj, mat4Mul, mat4Invert, PLANE, createConstraint, WORLD, AXIS, DIAL, POINT, mapDirection, pixelRatio, rayHitSphere, unproject, SPHERE, dirFromAzEl, DIRECTION, mapLocation, MATRIX, PoseHelm, cameraToPose, EYE, SELF, qToMat4, cameraEye, cameraFromPose, CameraTrack, PoseTrack, qFromAxisAngle, SCREEN, cameraCopy, createCamera, coastAlive, coastStep, cameraOrbit, cameraPan, cameraDolly } from '@nakednous/tree';
1
+ import { WEBGL, cameraView, cameraProj, mat4Mul, mat4Invert, PLANE, createConstraint, WORLD, AXIS, DIAL, POINT, mapDirection, pixelRatio, rayHitSphere, unproject, SPHERE, dirFromAzEl, DIRECTION, mapLocation, MATRIX, PoseHelm, cameraToPose, EYE, SELF, qToMat4, cameraEye, cameraFromPose, CameraTrack, PoseTrack, qFromAxisAngle, NDC, mat4ToTransform, meshBounds, meshFlatten, meshNormals, meshGroups, SCREEN, cameraCopy, createCamera, coastAlive, coastStep, cameraOrbit, cameraPan, cameraDolly } from '@nakednous/tree';
2
2
 
3
3
  /**
4
4
  * @file The view bag — the matrices every pick, solve, label and orbit reads.
@@ -1300,11 +1300,69 @@ function poseHelm(host, opts) {
1300
1300
  * either decorates the track with TrackHandles: one VIEW handle per
1301
1301
  * draggable keyframe field (pos / eye / center), an optional rot DIAL per
1302
1302
  * keyframe about a declared axis, all on one router.
1303
+ *
1304
+ * add(depth) — the transport panel's + button, which passes its depth slider
1305
+ * (ui's contract) — authors a keyframe where the camera is looking: on a pose
1306
+ * track the pose lands at the centre of the installed camera's frustum at
1307
+ * that depth, aimed along the camera's own eye frame; on a camera track the
1308
+ * camera state is captured, the depth having nothing to place. A panel whose
1309
+ * depth row is hidden passes no depth, which reads the panel's default, 0.5.
1310
+ * The frame is the host's view bag — the matrices the last setCamera
1311
+ * installed — so the placement is the same on every bridge and needs no
1312
+ * renderer here. remove() is the panel's −: no argument retracts the last
1313
+ * keyframe, one per call, an index staying the core track's own remove.
1303
1314
  */
1304
1315
 
1305
1316
 
1317
+ // ── Placement — the + button's depth ───────────────────────────────────────
1318
+ //
1319
+ // The transport panel hands add() a depth in [0, 1] and expects a keyframe in
1320
+ // front of the camera. Writer-free placement: unproject the frustum's centre
1321
+ // through the view bag's P · V inverse and aim the pose along the bag's eye
1322
+ // frame (E = V⁻¹, its columns right / up / back). The depth is NDC-linear, as
1323
+ // the p5 transport's was — 0 the near plane's centre, 1 the far plane's — so
1324
+ // every bridge's own ndcZMin maps it without the caller knowing the API.
1325
+
1326
+ const _at = [0, 0, 0]; // the unprojected frustum centre
1327
+ const _dir = [0, 0, 0]; // the camera's forward
1328
+ const _up = [0, 0, 0]; // the camera's up
1329
+
1330
+ /**
1331
+ * Append a pose at the centre of the installed camera's frustum, `depth` of
1332
+ * the way from the near plane to the far one.
1333
+ *
1334
+ * @param {object} host
1335
+ * @param {function} add The track's core add, already bound.
1336
+ * @param {number} [depth] NDC-linear depth in [0, 1]; clamped, absent or NaN
1337
+ * reads the panel's own default, 0.5.
1338
+ * @returns {boolean} false when no camera is installed (a stale view bag).
1339
+ */
1340
+ function _addInFront(host, add, depth) {
1341
+ const view = host.view;
1342
+ if (!view || view.stale) return false;
1343
+ const d = Number.isFinite(depth) ? (depth < 0 ? 0 : depth > 1 ? 1 : depth) : 0.5;
1344
+ const ndcZ = view.ndcZMin + d * (1 - view.ndcZMin);
1345
+ mapLocation(_at, 0, 0, ndcZ, NDC, WORLD, view, view.vp, view.ndcZMin);
1346
+ const e = view.mat4Eye; // columns: right · up · back · eye
1347
+ _dir[0] = -e[8]; _dir[1] = -e[9]; _dir[2] = -e[10];
1348
+ _up[0] = e[4]; _up[1] = e[5]; _up[2] = e[6];
1349
+ add({ pos: [_at[0], _at[1], _at[2]], rot: { dir: _dir, up: _up } }, { deduplicate: false });
1350
+ return true;
1351
+ }
1352
+
1306
1353
  // ── Players ────────────────────────────────────────────────────────────────
1307
1354
 
1355
+ // remove(): the panel's −, retracting the last authored keyframe — one per
1356
+ // click, either kind of track. An index stays the core track's own remove, so
1357
+ // a caller editing by index is unaffected; an empty track removes nothing.
1358
+ function _wireRemove(track) {
1359
+ const coreRemove = track.remove.bind(track);
1360
+ track.remove = function (index) {
1361
+ return coreRemove(index == null ? track.keyframes.length - 1 : index);
1362
+ };
1363
+ return track;
1364
+ }
1365
+
1308
1366
  function _wirePoseTrack(host, track) {
1309
1367
  let player = null;
1310
1368
  track._onActivate = () => {
@@ -1324,8 +1382,22 @@ function poseTrack(host, opts) {
1324
1382
  const o = opts || {};
1325
1383
  const track = new PoseTrack();
1326
1384
  _wirePoseTrack(host, track);
1385
+
1386
+ // add(depth): the transport's + (see the module header). A spec, or an array
1387
+ // of specs, is the core track's own; no argument — the panel with its depth
1388
+ // row hidden — places at the panel's default depth, 0.5.
1389
+ const coreAdd = track.add.bind(track);
1390
+ let warned = false;
1391
+ track.add = function (spec, addOpts) {
1392
+ if (spec != null && typeof spec !== 'number') return coreAdd(spec, addOpts);
1393
+ if (!_addInFront(host, coreAdd, spec) && !warned) {
1394
+ warned = true;
1395
+ console.warn('[host] poseTrack: add(depth) places a pose against the installed camera, and the view bag is stale — no keyframe added. Call the bridge\'s setCamera first.');
1396
+ }
1397
+ };
1398
+
1327
1399
  if (o.handles) track.handles = new TrackHandles(host, track, o.handles, false);
1328
- return track;
1400
+ return _wireRemove(track);
1329
1401
  }
1330
1402
 
1331
1403
  /**
@@ -1344,7 +1416,9 @@ function cameraTrack(host, cam, opts) {
1344
1416
 
1345
1417
  const coreAdd = track.add.bind(track);
1346
1418
  track.add = function (spec, addOpts) {
1347
- if (spec == null) {
1419
+ // No spec, or the transport panel's depth: a camera keyframe *is* the
1420
+ // camera, so there is nothing a placement depth could move — capture.
1421
+ if (spec == null || typeof spec === 'number') {
1348
1422
  if (!state) return;
1349
1423
  spec = state;
1350
1424
  } else if (Array.isArray(spec)) {
@@ -1373,7 +1447,7 @@ function cameraTrack(host, cam, opts) {
1373
1447
  track._onDeactivate = () => { host.players.remove(player); apply(); };
1374
1448
 
1375
1449
  if (o.handles) track.handles = new TrackHandles(host, track, o.handles, true);
1376
- return track;
1450
+ return _wireRemove(track);
1377
1451
  }
1378
1452
 
1379
1453
  // ── TrackHandles ───────────────────────────────────────────────────────────
@@ -1923,12 +1997,203 @@ function parseObj(text) {
1923
1997
  }
1924
1998
 
1925
1999
  /**
1926
- * @file Media sources — images, video and Canvas2D rasters for a bridge's textures, OBJ models for its buffers.
2000
+ * @file glTF 2.0 → meshes in the arrays shape, the node hierarchy, skins and animation clips.
2001
+ * @module host/gltf
2002
+ * @license AGPL-3.0-only
2003
+ *
2004
+ * The format is glTF 2.0 (Khronos Group): a JSON document plus binary buffers,
2005
+ * or both in one GLB container. parseGlb splits a container; parseGltf reads
2006
+ * the document over its buffers into what a bridge uploads and what tree's
2007
+ * skin functions (clipSample, poseWorld, jointPalette) take as they are.
2008
+ *
2009
+ * Read: triangle primitives (POSITION, NORMAL, TANGENT, TEXCOORD_0, JOINTS_0,
2010
+ * WEIGHTS_0, indices), morph targets (POSITION and NORMAL deltas), the
2011
+ * material's base colour factor, nodes, skins, animations. Normalised integer
2012
+ * accessors arrive as floats; interleaved views are unpacked. Not read:
2013
+ * textures, cameras, other primitive modes, sparse accessors and compressed
2014
+ * geometry (Draco, meshopt) — the last two throw.
2015
+ *
2016
+ * Nodes are re-indexed parents first, and every node index in the result
2017
+ * (a mesh's, a skin's joints, a channel's) is the new one.
2018
+ */
2019
+
2020
+
2021
+ const GLB_MAGIC = 0x46546C67, CHUNK_JSON = 0x4E4F534A, CHUNK_BIN = 0x004E4942;
2022
+
2023
+ const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
2024
+ const TYPES = {
2025
+ 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array,
2026
+ };
2027
+ const NORMALISED = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 };
2028
+ const ATTRIBUTES = {
2029
+ POSITION: 'position', NORMAL: 'normal', TANGENT: 'tangent', TEXCOORD_0: 'texcoord',
2030
+ JOINTS_0: 'joints', WEIGHTS_0: 'weights',
2031
+ };
2032
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
2033
+
2034
+ /**
2035
+ * Split a GLB container into its JSON document and binary chunk.
2036
+ * @param {ArrayBuffer} buffer
2037
+ * @returns {{ json:object, bin:ArrayBuffer|null }}
2038
+ */
2039
+ function parseGlb(buffer) {
2040
+ const view = new DataView(buffer);
2041
+ if (view.getUint32(0, true) !== GLB_MAGIC) throw new Error('[host] gltf: not a GLB container.');
2042
+ if (view.getUint32(4, true) !== 2) throw new Error('[host] gltf: GLB version ' + view.getUint32(4, true) + ', 2 expected.');
2043
+ const end = Math.min(view.getUint32(8, true), buffer.byteLength);
2044
+ let json = null, bin = null;
2045
+ for (let at = 12; at + 8 <= end;) {
2046
+ const length = view.getUint32(at, true), type = view.getUint32(at + 4, true);
2047
+ const start = at + 8;
2048
+ if (type === CHUNK_JSON) json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, start, length)));
2049
+ else if (type === CHUNK_BIN && !bin) bin = buffer.slice(start, start + length);
2050
+ at = start + length + (-length & 3);
2051
+ }
2052
+ if (!json) throw new Error('[host] gltf: GLB without a JSON chunk.');
2053
+ return { json, bin };
2054
+ }
2055
+
2056
+ /**
2057
+ * Read a glTF document over its buffers.
2058
+ *
2059
+ * A part is one triangle primitive under one node: `mesh` in the
2060
+ * arrays shape (position, normal?, tangent?, texcoord?, joints?, weights?,
2061
+ * indices), `targets` its morph targets as delta arrays, `color` the
2062
+ * material's base colour factor, `node` the node it hangs from and `skin` its
2063
+ * skin's index or −1. A rigid mesh draws under its node's world matrix; a
2064
+ * skinned one under its skin's joint palette.
2065
+ *
2066
+ * @param {object} json The glTF document.
2067
+ * @param {ArrayBuffer[]} buffers One per `json.buffers` entry.
2068
+ * @returns {{ parts:{ name:string, node:number, skin:number, mesh:object,
2069
+ * targets:{ name:string, position?:object, normal?:object }[], color:number[] }[],
2070
+ * nodes:{ names:string[], parents:Int32Array, rest:Float32Array },
2071
+ * skins:{ name:string, joints:Uint16Array, inverseBind:Float32Array }[],
2072
+ * clips:{ name:string, duration:number, channels:object[] }[] }}
2073
+ * nodes.rest: the rest pose, ten numbers per node (translation, rotation
2074
+ * [x,y,z,w], scale). A clip channel: { node, path, interp, times, values }.
2075
+ */
2076
+ function parseGltf(json, buffers) {
2077
+ for (const ext of json.extensionsRequired || []) {
2078
+ if (/draco|meshopt/i.test(ext)) throw new Error('[host] gltf: ' + ext + ' is not supported.');
2079
+ }
2080
+ const cache = new Map();
2081
+
2082
+ const accessor = (index) => {
2083
+ if (cache.has(index)) return cache.get(index);
2084
+ const a = json.accessors[index];
2085
+ if (a.sparse) throw new Error('[host] gltf: sparse accessors are not supported.');
2086
+ const n = COMPONENTS[a.type], Type = TYPES[a.componentType], scale = a.normalized && NORMALISED[a.componentType];
2087
+ const data = new (scale ? Float32Array : Type)(a.count * n);
2088
+ if (a.bufferView !== undefined) {
2089
+ const bv = json.bufferViews[a.bufferView];
2090
+ const base = (bv.byteOffset || 0) + (a.byteOffset || 0), size = Type.BYTES_PER_ELEMENT;
2091
+ const stride = bv.byteStride || n * size;
2092
+ const view = new DataView(buffers[bv.buffer]);
2093
+ const get = {
2094
+ 5120: 'getInt8', 5121: 'getUint8', 5122: 'getInt16', 5123: 'getUint16', 5125: 'getUint32', 5126: 'getFloat32',
2095
+ }[a.componentType];
2096
+ for (let i = 0; i < a.count; i++) {
2097
+ for (let c = 0; c < n; c++) {
2098
+ const v = view[get](base + i * stride + c * size, true);
2099
+ data[i * n + c] = scale ? Math.max(v / scale, -1) : v;
2100
+ }
2101
+ }
2102
+ }
2103
+ const out = { numComponents: n, data };
2104
+ cache.set(index, out);
2105
+ return out;
2106
+ };
2107
+
2108
+ // nodes, parents first
2109
+ const src = json.nodes || [], parentOf = new Array(src.length).fill(-1);
2110
+ src.forEach((node, i) => { for (const c of node.children || []) parentOf[c] = i; });
2111
+ const order = [], remap = new Array(src.length).fill(-1);
2112
+ const visit = (i) => { remap[i] = order.length; order.push(i); for (const c of src[i].children || []) visit(c); };
2113
+ src.forEach((_, i) => { if (parentOf[i] < 0) visit(i); });
2114
+
2115
+ const names = [], parents = new Int32Array(order.length), rest = new Float32Array(order.length * 10);
2116
+ const xf = { pos: [0, 0, 0], rot: [0, 0, 0, 1], scl: [1, 1, 1] };
2117
+ order.forEach((old, i) => {
2118
+ const node = src[old];
2119
+ names.push(node.name || '');
2120
+ parents[i] = parentOf[old] < 0 ? -1 : remap[parentOf[old]];
2121
+ let t = node.translation || [0, 0, 0], r = node.rotation || [0, 0, 0, 1], s = node.scale || [1, 1, 1];
2122
+ if (node.matrix) { mat4ToTransform(xf, node.matrix); t = xf.pos; r = xf.rot; s = xf.scl; }
2123
+ rest.set(t, i * 10); rest.set(r, i * 10 + 3); rest.set(s, i * 10 + 7);
2124
+ });
2125
+
2126
+ // parts: one per triangle primitive per node
2127
+ const primitives = (json.meshes || []).map((mesh) => {
2128
+ const out = [];
2129
+ for (const prim of mesh.primitives) {
2130
+ if ((prim.mode ?? 4) !== 4 || prim.attributes.POSITION === undefined) continue;
2131
+ const arrays = {};
2132
+ for (const [semantic, key] of Object.entries(ATTRIBUTES)) {
2133
+ if (prim.attributes[semantic] !== undefined) arrays[key] = accessor(prim.attributes[semantic]);
2134
+ }
2135
+ const count = arrays.position.data.length / 3;
2136
+ const data = prim.indices !== undefined
2137
+ ? Uint32Array.from(accessor(prim.indices).data)
2138
+ : Uint32Array.from({ length: count }, (_, i) => i);
2139
+ arrays.indices = { numComponents: 3, data };
2140
+ const targetNames = (mesh.extras && mesh.extras.targetNames) || [];
2141
+ const targets = (prim.targets || []).map((target, i) => {
2142
+ const t = { name: targetNames[i] || '' };
2143
+ if (target.POSITION !== undefined) t.position = accessor(target.POSITION);
2144
+ if (target.NORMAL !== undefined) t.normal = accessor(target.NORMAL);
2145
+ return t;
2146
+ });
2147
+ const material = prim.material !== undefined ? json.materials[prim.material] : null;
2148
+ const pbr = material && material.pbrMetallicRoughness;
2149
+ out.push({ arrays, targets, color: (pbr && pbr.baseColorFactor ? pbr.baseColorFactor : [1, 1, 1, 1]).slice() });
2150
+ }
2151
+ return { name: mesh.name || '', primitives: out };
2152
+ });
2153
+
2154
+ const parts = [];
2155
+ order.forEach((old, i) => {
2156
+ const node = src[old];
2157
+ if (node.mesh === undefined) return;
2158
+ const mesh = primitives[node.mesh];
2159
+ for (const p of mesh.primitives) {
2160
+ parts.push({ name: mesh.name || names[i], node: i, skin: node.skin ?? -1, mesh: p.arrays, targets: p.targets, color: p.color });
2161
+ }
2162
+ });
2163
+
2164
+ const skins = (json.skins || []).map((skin) => {
2165
+ const joints = Uint16Array.from(skin.joints, j => remap[j]);
2166
+ const inverseBind = skin.inverseBindMatrices !== undefined
2167
+ ? Float32Array.from(accessor(skin.inverseBindMatrices).data)
2168
+ : Float32Array.from({ length: joints.length * 16 }, (_, k) => IDENTITY[k % 16]);
2169
+ return { name: skin.name || '', joints, inverseBind };
2170
+ });
2171
+
2172
+ const clips = (json.animations || []).map((anim) => {
2173
+ let duration = 0;
2174
+ const channels = [];
2175
+ for (const ch of anim.channels) {
2176
+ if (ch.target.node === undefined) continue;
2177
+ const sampler = anim.samplers[ch.sampler];
2178
+ const times = accessor(sampler.input).data, values = accessor(sampler.output).data;
2179
+ if (times.length) duration = Math.max(duration, times[times.length - 1]);
2180
+ channels.push({ node: remap[ch.target.node], path: ch.target.path, interp: sampler.interpolation || 'LINEAR', times, values });
2181
+ }
2182
+ return { name: anim.name || '', duration, channels };
2183
+ });
2184
+
2185
+ return { parts, nodes: { names, parents, rest }, skins, clips };
2186
+ }
2187
+
2188
+ /**
2189
+ * @file Media sources — images, video and Canvas2D rasters for a bridge's textures, models for its buffers.
1927
2190
  * @module host/media
1928
2191
  * @license AGPL-3.0-only
1929
2192
  *
1930
- * A model arrives as arrays for a bridge's buffer upload: loadModel fetches an
1931
- * OBJ file and parses it (host/obj).
2193
+ * A mesh arrives as arrays for a bridge's buffer upload, its bounds beside
2194
+ * them: loadMesh. A model keeps a file's structure — parts, hierarchy, skins,
2195
+ * clips: loadModel. Both read OBJ (host/obj) and glTF 2.0 (host/gltf), or
2196
+ * another format through a parser the caller passes.
1932
2197
  *
1933
2198
  * What a texture upload reads from: an ImageBitmap fetched from a URL, a
1934
2199
  * hidden <video> element from a file or the user's camera, a bitmap drawn
@@ -1955,26 +2220,151 @@ async function loadImage(url, opts) {
1955
2220
  return o.bitmap ? createImageBitmap(blob, o.bitmap) : createImageBitmap(blob);
1956
2221
  }
1957
2222
 
2223
+ const _ONE_NODE = () => ({ names: [''], parents: Int32Array.of(-1), rest: Float32Array.of(0, 0, 0, 0, 0, 0, 1, 1, 1, 1) });
2224
+ const _part = (mesh) => ({ name: '', node: 0, skin: -1, mesh, targets: [], color: [1, 1, 1, 1] });
2225
+
2226
+ // A morph target's deltas expanded through its mesh's indices; a target carries no indices of its own.
2227
+ function _flatTarget(target, indices) {
2228
+ const flat = meshFlatten(target, indices);
2229
+ delete flat.indices;
2230
+ return flat;
2231
+ }
2232
+
2233
+ // The normals option applied to every part, then each mesh's bounds; parts sharing a mesh share the result.
2234
+ // undefined / true the file's normals; smooth ones where a mesh has none
2235
+ // 'smooth' always recomputed, summed per position (tree's meshGroups), so a faceted file smooths
2236
+ // 'flat' always recomputed on the flattened mesh (tree's meshFlatten), morph targets flattened with it
2237
+ // false nothing computed
2238
+ function _finish(model, o) {
2239
+ const mode = o.normals === undefined || o.normals === true ? 'fill' : o.normals;
2240
+ if (mode !== 'fill' && mode !== 'smooth' && mode !== 'flat' && mode !== false) {
2241
+ throw new Error("[host] model: normals is one of 'smooth', 'flat', false, or left out.");
2242
+ }
2243
+ const done = new Map();
2244
+ for (const part of model.parts) {
2245
+ const source = part.mesh;
2246
+ if (done.has(source)) {
2247
+ part.mesh = done.get(source);
2248
+ if (mode === 'flat') part.targets = part.targets.map(t => _flatTarget(t, source.indices.data));
2249
+ continue;
2250
+ }
2251
+ let mesh = source;
2252
+ const bounds = meshBounds({ min: [0, 0, 0], max: [0, 0, 0], center: [0, 0, 0], diag: 0 }, mesh.position.data);
2253
+ if (mode === 'flat') {
2254
+ mesh = meshFlatten(source);
2255
+ const p = mesh.position.data;
2256
+ mesh.normal = { numComponents: 3, data: meshNormals(new Float32Array(p.length), p, mesh.indices.data) };
2257
+ part.targets = part.targets.map(t => _flatTarget(t, source.indices.data));
2258
+ } else if (mode === 'smooth' || (mode === 'fill' && !mesh.normal)) {
2259
+ const p = mesh.position.data;
2260
+ const groups = meshGroups(new Int32Array(p.length / 3), p, 1e-6 * bounds.diag);
2261
+ mesh.normal = { numComponents: 3, data: meshNormals(new Float32Array(p.length), p, mesh.indices.data, { groups }) };
2262
+ }
2263
+ mesh.bounds = bounds;
2264
+ done.set(source, mesh);
2265
+ part.mesh = mesh;
2266
+ }
2267
+ return model;
2268
+ }
2269
+
2270
+ // Fetch and parse into the model shape, before the normals and bounds.
2271
+ async function _load(url, o, what) {
2272
+ const get = async (at, init) => {
2273
+ const res = await fetch(at, init);
2274
+ if (!res.ok) throw new Error('[host] ' + what + ': ' + at + ' → ' + res.status);
2275
+ return res;
2276
+ };
2277
+ if (typeof o.parse === 'function') {
2278
+ const mesh = await o.parse(await (await get(url, o.fetch)).arrayBuffer());
2279
+ return { parts: [_part(mesh)], nodes: _ONE_NODE(), skins: [], clips: [] };
2280
+ }
2281
+ const format = (o.format || (/\.(\w+)(?:[?#].*)?$/.exec(url) || [])[1] || '').toLowerCase();
2282
+ if (format !== 'obj' && format !== 'glb' && format !== 'gltf') {
2283
+ throw new Error('[host] ' + what + ': ' + url + ' → unknown format; opts.format is one of obj, glb, gltf, or pass opts.parse.');
2284
+ }
2285
+ const res = await get(url, o.fetch);
2286
+ if (format === 'obj') return { parts: [_part(parseObj(await res.text()))], nodes: _ONE_NODE(), skins: [], clips: [] };
2287
+ if (format === 'glb') {
2288
+ const { json, bin } = parseGlb(await res.arrayBuffer());
2289
+ return parseGltf(json, [bin]);
2290
+ }
2291
+ const json = await res.json();
2292
+ const base = typeof location !== 'undefined' ? new URL(url, location.href) : url;
2293
+ const resolve = (uri) => {
2294
+ if (/^data:/.test(uri)) return uri;
2295
+ try { return String(new URL(uri, base)); } catch (e) { return url.replace(/[^/]*$/, '') + uri; }
2296
+ };
2297
+ const buffers = await Promise.all((json.buffers || []).map(async b => (await get(resolve(b.uri), o.fetch)).arrayBuffer()));
2298
+ return parseGltf(json, buffers);
2299
+ }
2300
+
2301
+ /**
2302
+ * Fetch a mesh: one object in the arrays shape — position, indices, and
2303
+ * normal, tangent, texcoord, color, joints, weights when the file carries them
2304
+ * — with `bounds`, its extent `{ min, max, center, diag }`, beside them. A
2305
+ * bridge's buffer upload takes it as it is; `size / mesh.bounds.diag` is the
2306
+ * scale that fits any file to `size`.
2307
+ *
2308
+ * Formats: OBJ (host/obj), and glTF 2.0 as `.glb` or `.gltf` (host/gltf) when
2309
+ * the file holds one part. A file of several parts rejects, naming loadModel:
2310
+ * a mesh is never silently the first of many. `opts.parse` reads any other
2311
+ * format: it receives the fetched ArrayBuffer and returns the arrays shape.
2312
+ *
2313
+ * Normals. Left out, the option keeps the file's normals and gives smooth
2314
+ * ones to a mesh that has none. 'smooth' always recomputes them, summed per
2315
+ * position, so a file that arrives faceted or cut along a texture seam
2316
+ * smooths too. 'flat' always recomputes them on the flattened mesh — every
2317
+ * triangle owning its vertices, so the vertex count grows and the indices
2318
+ * count up. false computes nothing. The procedure, its limits (no crease
2319
+ * angle; grouping by grid cell, at 1e-6 of the bounds' diagonal) and its
2320
+ * sources are tree/mesh's. glTF viewers compute flat normals for a file
2321
+ * without any; here that is `normals: 'flat'`.
2322
+ *
2323
+ * @param {string} url
2324
+ * @param {{ fetch?:object, format?:string, parse?:function(ArrayBuffer):object,
2325
+ * normals?:'smooth'|'flat'|false }} [opts] fetch: the fetch init. format: 'obj' |
2326
+ * 'glb' | 'gltf' when the URL's extension does not say. parse: a parser for another
2327
+ * format. normals: as above.
2328
+ * @returns {Promise<object>} The mesh.
2329
+ */
2330
+ async function loadMesh(url, opts) {
2331
+ const o = opts || {};
2332
+ const model = await _load(url, o, 'mesh');
2333
+ if (model.parts.length !== 1) {
2334
+ throw new Error('[host] mesh: ' + url + ' → ' + model.parts.length + ' parts; loadModel keeps them apart.');
2335
+ }
2336
+ return _finish(model, o).parts[0].mesh;
2337
+ }
2338
+
1958
2339
  /**
1959
- * Fetch an OBJ model into the arrays shape: position and indices, plus normal
1960
- * and texcoord when the file carries them. twgl's createBufferInfoFromArrays
1961
- * takes the result as it is.
2340
+ * Fetch a model with its structure — what a rig, morph targets or animation
2341
+ * need; a single mesh is loadMesh's:
1962
2342
  *
1963
- * Faces are triangulated and each distinct position / uv / normal triple
1964
- * becomes one vertex. Materials are ignored.
2343
+ * { parts: [{ name, node, skin, mesh, targets, color }], nodes, skins, clips }
2344
+ *
2345
+ * A part is one triangle primitive under one node: `mesh` as loadMesh returns
2346
+ * it (the arrays shape with `bounds`, in the part's own space), `targets` its
2347
+ * morph targets as delta arrays, `color` its base colour [r, g, b, a], `node`
2348
+ * the node it hangs from and `skin` its skin's index or −1. `nodes` is the
2349
+ * hierarchy `{ names, parents, rest }`, parents first, `rest` a pose of ten
2350
+ * numbers per node; `skins` are `{ name, joints, inverseBind }` and `clips`
2351
+ * `{ name, duration, channels }` — what tree's clipSample, poseWorld and
2352
+ * jointPalette take. A rigid part draws under its node's world matrix, a
2353
+ * skinned one under its skin's joint palette. An OBJ file is one white part
2354
+ * under one identity node.
2355
+ *
2356
+ * The options are loadMesh's; `normals` applies to every part, 'flat'
2357
+ * flattening each part's morph targets with its mesh.
1965
2358
  *
1966
2359
  * @param {string} url
1967
- * @param {{ fetch?:object }} [opts] fetch: the fetch init (credentials, mode, …).
1968
- * @returns {Promise<{ position:{numComponents:number,data:Float32Array},
1969
- * normal?:{numComponents:number,data:Float32Array},
1970
- * texcoord?:{numComponents:number,data:Float32Array},
1971
- * indices:{numComponents:number,data:Uint32Array} }>}
2360
+ * @param {{ fetch?:object, format?:string, parse?:function(ArrayBuffer):object,
2361
+ * normals?:'smooth'|'flat'|false }} [opts]
2362
+ * @returns {Promise<{ parts:object[], nodes:{ names:string[], parents:Int32Array, rest:Float32Array },
2363
+ * skins:object[], clips:object[] }>}
1972
2364
  */
1973
2365
  async function loadModel(url, opts) {
1974
2366
  const o = opts || {};
1975
- const res = await fetch(url, o.fetch);
1976
- if (!res.ok) throw new Error('[host] model: ' + url + ' → ' + res.status);
1977
- return parseObj(await res.text());
2367
+ return _finish(await _load(url, o, 'model'), o);
1978
2368
  }
1979
2369
 
1980
2370
  /**
@@ -2478,7 +2868,7 @@ function createOrbit(host, cam, opts) {
2478
2868
  * host.cameraHelm(cam, opts) · host.poseHelm(opts)
2479
2869
  * host.poseTrack(opts) · host.cameraTrack(cam, opts)
2480
2870
  * host.hid(opts) · host.gamepad(opts)
2481
- * host.image(url) · host.video(opts) · host.raster(draw, w, h) · host.model(url)
2871
+ * host.image(url) · host.video(opts) · host.raster(draw, w, h) · host.mesh(url) · host.model(url)
2482
2872
  * host.labels // the DOM label layer, created on first access
2483
2873
  * host.orbit(cam, opts)
2484
2874
  * host.tick(dt) // external-loop mode
@@ -2573,7 +2963,9 @@ function createHost(canvas, opts) {
2573
2963
  video(opts) { return host.register(createVideo(opts)); },
2574
2964
  /** Draw through a 2D context into a texture source — see host/media. */
2575
2965
  raster(draw, w, h, opts) { return raster(draw, w, h, opts); },
2576
- /** Fetch an OBJ model into the arrays shape — see host/media. */
2966
+ /** Fetch one mesh — the arrays shape with its bounds — from an OBJ or glTF file, or any format through opts.parse — see host/media. */
2967
+ mesh(url, opts) { return loadMesh(url, opts); },
2968
+ /** Fetch a model with its structure: parts, nodes, skins, clips — see host/media. */
2577
2969
  model(url, opts) { return loadModel(url, opts); },
2578
2970
  /**
2579
2971
  * The label layer — see host/labels. Created on first access, so a
@@ -2630,5 +3022,5 @@ function createHost(canvas, opts) {
2630
3022
  return host;
2631
3023
  }
2632
3024
 
2633
- export { GAMEPAD_MAP, HID_FILTERS, Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createGamepad, createHid, createHost, createLabels, createLoop, createOrbit, createPlayers, createPointer, createVideo, createView, decodeSpaceNavigator, helmBasis, isConstraint, loadImage, loadModel, measureCanvas, observeCanvas, poseHelm, poseTrack, raster, validConstraint };
3025
+ 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, loadMesh, loadModel, measureCanvas, observeCanvas, poseHelm, poseTrack, raster, validConstraint };
2634
3026
  //# sourceMappingURL=index.js.map