@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 +13 -3
- package/dist/index.js +415 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
1931
|
-
*
|
|
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
|
|
1960
|
-
*
|
|
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
|
-
*
|
|
1964
|
-
*
|
|
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
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
1970
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|