@uptimizr/playcanvas 0.1.0
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/LICENSE +201 -0
- package/README.md +193 -0
- package/dist/collector.d.ts +259 -0
- package/dist/collector.d.ts.map +1 -0
- package/dist/collector.js +1081 -0
- package/dist/collector.js.map +1 -0
- package/dist/connector.d.ts +17 -0
- package/dist/connector.d.ts.map +1 -0
- package/dist/connector.js +30 -0
- package/dist/connector.js.map +1 -0
- package/dist/device.d.ts +17 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/device.js +40 -0
- package/dist/device.js.map +1 -0
- package/dist/graphics.d.ts +16 -0
- package/dist/graphics.d.ts.map +1 -0
- package/dist/graphics.js +57 -0
- package/dist/graphics.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy.d.ts +46 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +225 -0
- package/dist/proxy.js.map +1 -0
- package/dist/raycast.d.ts +68 -0
- package/dist/raycast.d.ts.map +1 -0
- package/dist/raycast.js +163 -0
- package/dist/raycast.js.map +1 -0
- package/dist/renderer.d.ts +48 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +62 -0
- package/dist/renderer.js.map +1 -0
- package/dist/scene.d.ts +21 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +51 -0
- package/dist/scene.js.map +1 -0
- package/dist/trackScene.d.ts +114 -0
- package/dist/trackScene.d.ts.map +1 -0
- package/dist/trackScene.js +72 -0
- package/dist/trackScene.js.map +1 -0
- package/dist/uptimizr-playcanvas.global.js +66 -0
- package/dist/uptimizr-playcanvas.js +66 -0
- package/dist/vec.d.ts +10 -0
- package/dist/vec.d.ts.map +1 -0
- package/dist/vec.js +9 -0
- package/dist/vec.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { classifyCameraGesture, resolveCadence, toCanonicalDirection, toCanonicalPosition, toCanonicalQuat, } from "@uptimizr/sdk-core";
|
|
2
|
+
import { clamp01, toVec3 } from "./vec.js";
|
|
3
|
+
import { createGazeRaycaster, createSceneRaycaster } from "./raycast.js";
|
|
4
|
+
/**
|
|
5
|
+
* Map a DOM pointer's `pointerType` to an Uptimizr {@link InputSource} (ADR 0011)
|
|
6
|
+
* — identical mapping to the Babylon / three connectors.
|
|
7
|
+
*/
|
|
8
|
+
function pointerSource(ev) {
|
|
9
|
+
const t = ev.pointerType;
|
|
10
|
+
if (t === "mouse" || t === "pen" || t === "touch")
|
|
11
|
+
return t;
|
|
12
|
+
return typeof t === "string" && t.length > 0 ? "other" : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* True when the rendering canvas currently holds the browser Pointer Lock (ADR
|
|
16
|
+
* 0034). While locked the OS cursor is hidden and `clientX/Y` freeze, so the
|
|
17
|
+
* connector treats the crosshair (viewport centre) as the pointer. The canvas is
|
|
18
|
+
* read lazily and only when a lock is actually held, so headless capture (no
|
|
19
|
+
* `document`) is never touched.
|
|
20
|
+
*/
|
|
21
|
+
function isPointerLocked(getCanvas) {
|
|
22
|
+
if (typeof document === "undefined")
|
|
23
|
+
return false;
|
|
24
|
+
const locked = document.pointerLockElement;
|
|
25
|
+
return locked != null && locked === getCanvas();
|
|
26
|
+
}
|
|
27
|
+
function vec3Close(a, b, eps) {
|
|
28
|
+
return (Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps && Math.abs(a[2] - b[2]) <= eps);
|
|
29
|
+
}
|
|
30
|
+
/** True when two poses are equal within `eps`. */
|
|
31
|
+
function poseUnchanged(a, b, eps) {
|
|
32
|
+
if (!vec3Close(a.position, b.position, eps))
|
|
33
|
+
return false;
|
|
34
|
+
if (!vec3Close(a.direction, b.direction, eps))
|
|
35
|
+
return false;
|
|
36
|
+
if ((a.fov === undefined) !== (b.fov === undefined))
|
|
37
|
+
return false;
|
|
38
|
+
if (a.fov !== undefined && b.fov !== undefined && Math.abs(a.fov - b.fov) > eps)
|
|
39
|
+
return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
function sub3(a, b) {
|
|
43
|
+
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
44
|
+
}
|
|
45
|
+
function dot3(a, b) {
|
|
46
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
47
|
+
}
|
|
48
|
+
function len3(a) {
|
|
49
|
+
return Math.hypot(a[0], a[1], a[2]);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Derive the per-tick capture rate from a `sampling.nodes` entry: either a bare
|
|
53
|
+
* {@link SampleRate} (root-only, ADR 0027) or a {@link NodeSamplingConfig} whose
|
|
54
|
+
* `hz` carries the rate (subtree, ADR 0033).
|
|
55
|
+
*/
|
|
56
|
+
function nodeRate(entry) {
|
|
57
|
+
if (entry !== null && typeof entry === "object")
|
|
58
|
+
return entry.hz;
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a `sampling.nodes` entry into a {@link SubtreeConfig}, or `null` when it
|
|
63
|
+
* is a bare rate or declares no `include` (root-only). Applies the ADR 0033 caps
|
|
64
|
+
* (`maxDepth` 8, `maxNodes` 64) and normalises `exclude` to a set.
|
|
65
|
+
*/
|
|
66
|
+
function nodeSubtree(entry) {
|
|
67
|
+
if (entry === null || typeof entry !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const include = entry.include;
|
|
70
|
+
const ok = include === "*" || (Array.isArray(include) && include.length > 0);
|
|
71
|
+
if (!ok)
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
include: include,
|
|
75
|
+
maxDepth: entry.maxDepth ?? 8,
|
|
76
|
+
maxNodes: entry.maxNodes ?? 64,
|
|
77
|
+
exclude: new Set(entry.exclude ?? []),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Breadth-first walk of an actor's descendants for Tier-1 subtree capture (ADR
|
|
82
|
+
* 0033). Visits transform nodes only — cameras are refused (and not descended,
|
|
83
|
+
* "events live once") and excluded names prune their subtree. Each kept node is
|
|
84
|
+
* returned with its `/`-joined path from the actor (matched by name on replay).
|
|
85
|
+
* The walk is bounded by `maxDepth`/`maxNodes` with deterministic FIFO
|
|
86
|
+
* truncation so a deep/wide hierarchy cannot blow up the wire.
|
|
87
|
+
*/
|
|
88
|
+
function collectSubtree(root, cfg) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const includeAll = cfg.include === "*";
|
|
91
|
+
const includeSet = includeAll ? null : new Set(cfg.include);
|
|
92
|
+
const queue = [];
|
|
93
|
+
for (const child of root.children ?? []) {
|
|
94
|
+
queue.push({ node: child, path: child.name ?? "", depth: 1 });
|
|
95
|
+
}
|
|
96
|
+
while (queue.length > 0 && out.length < cfg.maxNodes) {
|
|
97
|
+
const { node, path, depth } = queue.shift();
|
|
98
|
+
const name = node.name;
|
|
99
|
+
if (typeof name !== "string" || name.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
if (cfg.exclude.has(name))
|
|
102
|
+
continue; // prune the whole subtree
|
|
103
|
+
if (isCameraEntity(node))
|
|
104
|
+
continue; // refuse cameras and don't descend
|
|
105
|
+
if (includeAll || includeSet.has(name))
|
|
106
|
+
out.push({ childPath: path, node });
|
|
107
|
+
if (depth < cfg.maxDepth) {
|
|
108
|
+
for (const child of node.children ?? []) {
|
|
109
|
+
const cn = child.name ?? "";
|
|
110
|
+
queue.push({ node: child, path: path ? `${path}/${cn}` : cn, depth: depth + 1 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** True when `node` is a camera — refused for `node_transform` (ADR 0027 §7). */
|
|
117
|
+
function isCameraEntity(node) {
|
|
118
|
+
return node.camera != null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read a PlayCanvas node's world transform into a `node_transform` sample, then
|
|
122
|
+
* convert it from PlayCanvas' right-handed frame to the canonical frame (ADR
|
|
123
|
+
* 0018): the position negates Z and the quaternion is reflected `(−x,−y,z,w)`.
|
|
124
|
+
* Scale is invariant under the reflection and is omitted when identity so the
|
|
125
|
+
* common static-scale case stays off the wire (ADR 0027).
|
|
126
|
+
*/
|
|
127
|
+
function readNodeTransform(node, scaleEps) {
|
|
128
|
+
const p = node.getPosition?.() ?? { x: 0, y: 0, z: 0 };
|
|
129
|
+
const q = node.getRotation?.() ?? { x: 0, y: 0, z: 0, w: 1 };
|
|
130
|
+
const sample = {
|
|
131
|
+
position: toCanonicalPosition([p.x, p.y, p.z], "right"),
|
|
132
|
+
rotation: toCanonicalQuat([q.x, q.y, q.z, q.w], "right"),
|
|
133
|
+
};
|
|
134
|
+
const s = node.getWorldTransform?.()?.getScale?.();
|
|
135
|
+
if (s &&
|
|
136
|
+
(Math.abs(s.x - 1) > scaleEps || Math.abs(s.y - 1) > scaleEps || Math.abs(s.z - 1) > scaleEps)) {
|
|
137
|
+
sample.scale = toVec3(s);
|
|
138
|
+
}
|
|
139
|
+
return sample;
|
|
140
|
+
}
|
|
141
|
+
/** True when two node samples are equal within `eps` (scale presence must also match). */
|
|
142
|
+
function nodeSampleUnchanged(a, b, eps) {
|
|
143
|
+
if (!vec3Close(a.position, b.position, eps))
|
|
144
|
+
return false;
|
|
145
|
+
if (Math.abs(a.rotation[0] - b.rotation[0]) > eps ||
|
|
146
|
+
Math.abs(a.rotation[1] - b.rotation[1]) > eps ||
|
|
147
|
+
Math.abs(a.rotation[2] - b.rotation[2]) > eps ||
|
|
148
|
+
Math.abs(a.rotation[3] - b.rotation[3]) > eps) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if ((a.scale === undefined) !== (b.scale === undefined))
|
|
152
|
+
return false;
|
|
153
|
+
if (a.scale && b.scale && !vec3Close(a.scale, b.scale, eps))
|
|
154
|
+
return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a declared {@link PlayCanvasActor} to a live node, or `null` when it is
|
|
159
|
+
* not (yet) in the scene. A function is called each time (robust to load order
|
|
160
|
+
* and disposal); a string is looked up by entity name via `app.root.findByName`;
|
|
161
|
+
* a direct reference is returned as-is.
|
|
162
|
+
*/
|
|
163
|
+
function resolveActorNode(root, actor) {
|
|
164
|
+
if (typeof actor === "function") {
|
|
165
|
+
return actor() ?? null;
|
|
166
|
+
}
|
|
167
|
+
if (typeof actor === "string") {
|
|
168
|
+
return root.findByName?.(actor) ?? null;
|
|
169
|
+
}
|
|
170
|
+
return actor ?? null;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Collect the unique skeleton bones referenced by a node's mesh instances
|
|
174
|
+
* (`render` and legacy `model`), de-duplicated by name in first-seen order. A
|
|
175
|
+
* skinned mesh shares one bone set across its instances, so dedup keeps each
|
|
176
|
+
* named bone once. Returns an empty array when the node carries no skin.
|
|
177
|
+
*/
|
|
178
|
+
function collectSkinBones(node) {
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
const out = [];
|
|
181
|
+
const instances = [...(node.render?.meshInstances ?? []), ...(node.model?.meshInstances ?? [])];
|
|
182
|
+
for (const mi of instances) {
|
|
183
|
+
const bones = mi.skinInstance?.bones;
|
|
184
|
+
if (!bones)
|
|
185
|
+
continue;
|
|
186
|
+
for (const bone of bones) {
|
|
187
|
+
const name = bone?.name;
|
|
188
|
+
if (typeof name !== "string" || seen.has(name))
|
|
189
|
+
continue;
|
|
190
|
+
seen.add(name);
|
|
191
|
+
out.push(bone);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the bones to capture for one actor: the allowlisted names in order, or
|
|
198
|
+
* — for the explicit `"*"` wildcard — every named bone in first-seen order.
|
|
199
|
+
* Returns an empty array when the node carries no skin.
|
|
200
|
+
*/
|
|
201
|
+
function resolvePlayCanvasBones(node, include) {
|
|
202
|
+
const bones = collectSkinBones(node);
|
|
203
|
+
if (bones.length === 0)
|
|
204
|
+
return [];
|
|
205
|
+
if (include === "*")
|
|
206
|
+
return bones;
|
|
207
|
+
const byName = new Map();
|
|
208
|
+
for (const b of bones)
|
|
209
|
+
if (typeof b.name === "string")
|
|
210
|
+
byName.set(b.name, b);
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const name of include) {
|
|
213
|
+
const bone = byName.get(name);
|
|
214
|
+
if (bone)
|
|
215
|
+
out.push(bone);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read a PlayCanvas bone's skeleton-local transform into a canonical-frame
|
|
221
|
+
* `node_transform` sample (ADR 0027 Tier 2). The local TRS is read via the
|
|
222
|
+
* `getLocal*` accessors and reflected into the canonical frame the same way a
|
|
223
|
+
* world transform is (the reflection conjugates a local transform identically) —
|
|
224
|
+
* Z-negated position, reflected quaternion `(−x,−y,z,w)`. Scale is invariant and
|
|
225
|
+
* omitted when identity. Returns `null` when the bone exposes no local pose.
|
|
226
|
+
*/
|
|
227
|
+
function readPlayCanvasBoneTransform(bone, scaleEps) {
|
|
228
|
+
if (!bone.getLocalPosition || !bone.getLocalRotation)
|
|
229
|
+
return null;
|
|
230
|
+
const p = bone.getLocalPosition();
|
|
231
|
+
const q = bone.getLocalRotation();
|
|
232
|
+
const sample = {
|
|
233
|
+
position: toCanonicalPosition([p.x, p.y, p.z], "right"),
|
|
234
|
+
rotation: toCanonicalQuat([q.x, q.y, q.z, q.w], "right"),
|
|
235
|
+
};
|
|
236
|
+
const s = bone.getLocalScale?.();
|
|
237
|
+
if (s &&
|
|
238
|
+
(Math.abs(s.x - 1) > scaleEps || Math.abs(s.y - 1) > scaleEps || Math.abs(s.z - 1) > scaleEps)) {
|
|
239
|
+
sample.scale = toVec3(s);
|
|
240
|
+
}
|
|
241
|
+
return sample;
|
|
242
|
+
}
|
|
243
|
+
/** Round an AABB to mm precision so tiny float jitter doesn't re-send the box. */
|
|
244
|
+
function roundAabb(b) {
|
|
245
|
+
return b.map((v) => Math.round(v * 1000) / 1000);
|
|
246
|
+
}
|
|
247
|
+
/** True when two AABBs match within `eps` on every axis. */
|
|
248
|
+
function aabbClose(a, b, eps) {
|
|
249
|
+
for (let i = 0; i < 6; i++)
|
|
250
|
+
if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > eps)
|
|
251
|
+
return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
function meshInstancesOf(node) {
|
|
255
|
+
return node.render?.meshInstances ?? node.model?.meshInstances ?? [];
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Read a renderable node's world-space AABB as the union of its mesh-instance
|
|
259
|
+
* world AABBs. PlayCanvas exposes each instance's world `aabb` directly
|
|
260
|
+
* (`getMin`/`getMax`) — no manual corner transform (unlike three).
|
|
261
|
+
*/
|
|
262
|
+
function readWorldBounds(node) {
|
|
263
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
264
|
+
let found = false;
|
|
265
|
+
for (const mi of meshInstancesOf(node)) {
|
|
266
|
+
const box = mi.aabb;
|
|
267
|
+
if (!box || typeof box.getMin !== "function" || typeof box.getMax !== "function")
|
|
268
|
+
continue;
|
|
269
|
+
const lo = box.getMin();
|
|
270
|
+
const hi = box.getMax();
|
|
271
|
+
found = true;
|
|
272
|
+
if (lo.x < minX)
|
|
273
|
+
minX = lo.x;
|
|
274
|
+
if (lo.y < minY)
|
|
275
|
+
minY = lo.y;
|
|
276
|
+
if (lo.z < minZ)
|
|
277
|
+
minZ = lo.z;
|
|
278
|
+
if (hi.x > maxX)
|
|
279
|
+
maxX = hi.x;
|
|
280
|
+
if (hi.y > maxY)
|
|
281
|
+
maxY = hi.y;
|
|
282
|
+
if (hi.z > maxZ)
|
|
283
|
+
maxZ = hi.z;
|
|
284
|
+
}
|
|
285
|
+
if (!found)
|
|
286
|
+
return null;
|
|
287
|
+
return {
|
|
288
|
+
center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
|
|
289
|
+
radius: 0.5 * Math.hypot(maxX - minX, maxY - minY, maxZ - minZ),
|
|
290
|
+
aabb: [minX, minY, minZ, maxX, maxY, maxZ],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Whether a world-space bounding sphere is (roughly) in front of the camera.
|
|
295
|
+
* PlayCanvas exposes a per-camera `Frustum`, but reading it structurally without
|
|
296
|
+
* importing the engine is brittle, so the connector uses the forward half-space
|
|
297
|
+
* test the Babylon / three connectors also use as their fallback — anything in
|
|
298
|
+
* front of the camera counts. This is the documented PlayCanvas divergence from
|
|
299
|
+
* three's full VP-matrix frustum path (matches three's stub-camera behavior).
|
|
300
|
+
*/
|
|
301
|
+
function sphereInFront(center, radius, camPos, forward) {
|
|
302
|
+
return dot3(sub3(center, camPos), forward) + radius > 0;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Create the PlayCanvas connector as an sdk-core {@link Collector}. Register it
|
|
306
|
+
* with `client.use(...)`.
|
|
307
|
+
*
|
|
308
|
+
* It samples camera pose (view-direction heatmap), pointer movement and clicks
|
|
309
|
+
* (screen heatmaps), mesh picks (object engagement), and FPS (perf). It only reads
|
|
310
|
+
* from the scene — it never mutates it — and tears every listener, timer, and
|
|
311
|
+
* frame handler down on stop (ADR 0003: no cookies, no persistent ids).
|
|
312
|
+
*
|
|
313
|
+
* Device/GPU capabilities are captured separately via {@link "./device".readDeviceCaps}.
|
|
314
|
+
*
|
|
315
|
+
* ## PlayCanvas adaptations (vs. the three connector)
|
|
316
|
+
* - **Camera pose:** a PlayCanvas Entity's `forward` getter already returns the
|
|
317
|
+
* true world look direction (no local −Z negation), so it converts straight
|
|
318
|
+
* through `toCanonicalDirection`.
|
|
319
|
+
* - **FPS:** PlayCanvas computes FPS itself, so perf reads `app.stats.frame.fps`
|
|
320
|
+
* directly rather than deriving it from a frame-counter delta.
|
|
321
|
+
* - **"frame" cadence:** the connector owns no rAF loop; it subscribes to the
|
|
322
|
+
* engine's `frameend` event (the app's own render tick) and removes it on stop.
|
|
323
|
+
* - **World AABBs:** `meshInstance.aabb` is already a world-space box, so mesh
|
|
324
|
+
* visibility unions those directly instead of transforming local corners.
|
|
325
|
+
*/
|
|
326
|
+
export function playcanvasCollector(options) {
|
|
327
|
+
const { app, camera, sampleCameraMs = 1000, samplePerfMs = 2000, pointerMoveThrottleMs = 250, suppressIdleSamples = true, suppressIdlePerfSamples = false, cameraEpsilon = 1e-3, perfFpsThreshold = 1, cameraGestureSensitivity = 1, capture = {}, sampling = {}, } = options;
|
|
328
|
+
// Resolve each continuous channel's cadence (ADR 0012). An explicit `sampling`
|
|
329
|
+
// entry wins; otherwise we fall back to the legacy ms knob, preserving the old
|
|
330
|
+
// defaults and behavior. A channel resolved to "off" is not captured.
|
|
331
|
+
const cameraCadence = resolveCadence(sampling.camera, sampleCameraMs);
|
|
332
|
+
const perfCadence = resolveCadence(sampling.perf, samplePerfMs);
|
|
333
|
+
const pointerMoveCadence = resolveCadence(sampling.pointerMove, pointerMoveThrottleMs);
|
|
334
|
+
// Keyboard `input_action` allowlist (ADR 0023): only bound keys are recorded.
|
|
335
|
+
const keyBindings = options.keyBindings ?? {};
|
|
336
|
+
const hasKeyBindings = Object.keys(keyBindings).length > 0;
|
|
337
|
+
const want = {
|
|
338
|
+
camera: (capture.camera ?? true) && cameraCadence.mode !== "off",
|
|
339
|
+
pointerMove: (capture.pointerMove ?? true) && pointerMoveCadence.mode !== "off",
|
|
340
|
+
clicks: capture.clicks ?? true,
|
|
341
|
+
buttons: capture.buttons ?? true,
|
|
342
|
+
// Typed navigation gestures are on by default (ADR 0025): cheap, no PII.
|
|
343
|
+
cameraGesture: capture.cameraGesture ?? true,
|
|
344
|
+
meshPicks: capture.meshPicks ?? true,
|
|
345
|
+
perf: (capture.perf ?? true) && perfCadence.mode !== "off",
|
|
346
|
+
contextLoss: capture.contextLoss ?? true,
|
|
347
|
+
// Opt-in, off by default (privacy, ADR 0003).
|
|
348
|
+
meshVisibility: capture.meshVisibility ?? false,
|
|
349
|
+
hoverDwell: capture.hoverDwell ?? false,
|
|
350
|
+
// GPU/memory footprint is opt-in (privacy + cost, ADR 0003).
|
|
351
|
+
resourceSample: capture.resourceSample ?? false,
|
|
352
|
+
// Gaze raycast is opt-in (privacy + cost, ADR 0003 / ADR 0012): off unless
|
|
353
|
+
// enabled, and only meaningful when the camera channel is captured.
|
|
354
|
+
gaze: (capture.gaze ?? false) && (capture.camera ?? true) && cameraCadence.mode !== "off",
|
|
355
|
+
// Scene-actor capture is opt-in via actors + sampling.nodes (ADR 0027).
|
|
356
|
+
nodes: capture.nodes ?? true,
|
|
357
|
+
bones: capture.bones ?? true,
|
|
358
|
+
// Keyboard is opt-in: it requires an explicit binding allowlist (ADR 0023).
|
|
359
|
+
keyboard: (capture.keyboard ?? true) && hasKeyBindings,
|
|
360
|
+
};
|
|
361
|
+
// Scene-actor (`node_transform`, ADR 0027 Tier 1) configuration. Only ids that
|
|
362
|
+
// are BOTH declared in `actors` and given a rate in `sampling.nodes` are
|
|
363
|
+
// tracked (default OFF); each is driven by its own resolved cadence (ADR 0012).
|
|
364
|
+
const actorMap = options.actors ?? {};
|
|
365
|
+
const actorIds = want.nodes
|
|
366
|
+
? Object.keys(sampling.nodes ?? {}).filter((id) => {
|
|
367
|
+
const declared = Object.prototype.hasOwnProperty.call(actorMap, id);
|
|
368
|
+
if (!declared) {
|
|
369
|
+
console.warn(`[uptimizr] sampling.nodes["${id}"] has no matching entry in \`actors\`; ` +
|
|
370
|
+
"ignoring. Declare the node in `actors` to capture its transform.");
|
|
371
|
+
}
|
|
372
|
+
return declared;
|
|
373
|
+
})
|
|
374
|
+
: [];
|
|
375
|
+
const wantNodes = actorIds.length > 0;
|
|
376
|
+
// Skeleton bone (`node_transform` + `boneId`, ADR 0027 Tier 2) configuration.
|
|
377
|
+
// Only ids that are BOTH declared in `actors` and given a per-bone allowlist in
|
|
378
|
+
// `sampling.bones` are tracked (default OFF); each actor is driven by its own
|
|
379
|
+
// resolved cadence (ADR 0012).
|
|
380
|
+
const boneActorIds = want.bones
|
|
381
|
+
? Object.keys(sampling.bones ?? {}).filter((id) => {
|
|
382
|
+
const declared = Object.prototype.hasOwnProperty.call(actorMap, id);
|
|
383
|
+
if (!declared) {
|
|
384
|
+
console.warn(`[uptimizr] sampling.bones["${id}"] has no matching entry in \`actors\`; ` +
|
|
385
|
+
"ignoring. Declare the node in `actors` to capture its bones.");
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
const cfg = sampling.bones[id];
|
|
389
|
+
const include = cfg.include;
|
|
390
|
+
if (include !== "*" && (!Array.isArray(include) || include.length === 0)) {
|
|
391
|
+
console.warn(`[uptimizr] sampling.bones["${id}"].include is empty; ignoring. Provide bone ` +
|
|
392
|
+
'names or "*" to capture bones.');
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
})
|
|
397
|
+
: [];
|
|
398
|
+
const wantBones = boneActorIds.length > 0;
|
|
399
|
+
// Per-object dwell (`mesh_visibility`, #37) tuning.
|
|
400
|
+
const visOpts = options.meshVisibility ?? {};
|
|
401
|
+
const visWindowMs = visOpts.windowMs ?? 5000;
|
|
402
|
+
const visMeshAllowlist = visOpts.meshes && visOpts.meshes.length > 0 ? new Set(visOpts.meshes) : undefined;
|
|
403
|
+
const visCenteredCos = Math.cos(((visOpts.centeredAngleDeg ?? 12) * Math.PI) / 180);
|
|
404
|
+
const visMaxMeshes = visOpts.maxMeshes ?? 50;
|
|
405
|
+
const visBoundingBox = visOpts.boundingBox ?? false;
|
|
406
|
+
// Hover-hesitation (`hover_dwell`, #48) tuning.
|
|
407
|
+
const hoverOpts = options.hoverDwell ?? {};
|
|
408
|
+
const hoverMinDwellMs = hoverOpts.minDwellMs ?? 500;
|
|
409
|
+
const hoverMeshAllowlist = hoverOpts.meshes && hoverOpts.meshes.length > 0 ? new Set(hoverOpts.meshes) : undefined;
|
|
410
|
+
// GPU/memory footprint (`resource_sample`, #44) tuning.
|
|
411
|
+
const resourceOpts = options.resourceSample ?? {};
|
|
412
|
+
const resourceIntervalMs = resourceOpts.intervalMs ?? 15000;
|
|
413
|
+
// Pointer-move throttle in ms: a fixed interval throttles; "frame" means emit
|
|
414
|
+
// every move (no throttle). Discrete pointer events are never throttled.
|
|
415
|
+
const pointerThrottleMs = pointerMoveCadence.mode === "interval" ? pointerMoveCadence.ms : 0;
|
|
416
|
+
return {
|
|
417
|
+
name: "playcanvas",
|
|
418
|
+
start(ctx) {
|
|
419
|
+
const appView = app;
|
|
420
|
+
const timers = [];
|
|
421
|
+
const domListeners = [];
|
|
422
|
+
const frameCallbacks = [];
|
|
423
|
+
// Run-once-on-stop hooks (trailing flushes for windowed/episodic captures).
|
|
424
|
+
const stopCallbacks = [];
|
|
425
|
+
let frameendBound = false;
|
|
426
|
+
let disposed = false;
|
|
427
|
+
let lastPointerMove = 0;
|
|
428
|
+
let lastPose;
|
|
429
|
+
let lastFps;
|
|
430
|
+
// camera_gesture (ADR 0025) bracket state: the camera snapshot at
|
|
431
|
+
// pointer-down, diffed against pointer-up to classify the navigation.
|
|
432
|
+
let gestureStart = null;
|
|
433
|
+
const readFps = () => appView.stats?.frame?.fps ?? 0;
|
|
434
|
+
/** Vertical FOV in radians, converting from PlayCanvas' degrees + optional horizontalFov. */
|
|
435
|
+
const verticalFovRad = (cam) => {
|
|
436
|
+
if (!cam || typeof cam.fov !== "number")
|
|
437
|
+
return undefined;
|
|
438
|
+
const fovRad = (cam.fov * Math.PI) / 180;
|
|
439
|
+
if (cam.horizontalFov) {
|
|
440
|
+
const aspect = typeof cam.aspectRatio === "number" && cam.aspectRatio > 0 ? cam.aspectRatio : 1;
|
|
441
|
+
// Convert horizontal → vertical FOV: vfov = 2*atan(tan(hfov/2) / aspect).
|
|
442
|
+
return 2 * Math.atan(Math.tan(fovRad / 2) / aspect);
|
|
443
|
+
}
|
|
444
|
+
return fovRad;
|
|
445
|
+
};
|
|
446
|
+
// Gaze probe (`camera_sample.hitPoint`/`hitMesh`, ADR 0030): a reused
|
|
447
|
+
// camera-forward raycaster, built once. Created only when gaze is enabled so
|
|
448
|
+
// the common path allocates nothing. A caller may inject `options.gaze.probe`.
|
|
449
|
+
const gazeProbe = want.gaze
|
|
450
|
+
? (options.gaze?.probe ?? createGazeRaycaster(app, camera, options.gaze ?? {}))
|
|
451
|
+
: undefined;
|
|
452
|
+
const sampleCamera = () => {
|
|
453
|
+
const c = camera;
|
|
454
|
+
// PlayCanvas `forward` is the TRUE world-space look direction (no local −Z
|
|
455
|
+
// convention like three), so the plain Z-negation in `toCanonicalDirection`
|
|
456
|
+
// is correct here (ADR 0018).
|
|
457
|
+
const wp = c.getPosition();
|
|
458
|
+
const wd = c.forward;
|
|
459
|
+
const position = toCanonicalPosition([wp.x, wp.y, wp.z], "right");
|
|
460
|
+
const direction = toCanonicalDirection([wd.x, wd.y, wd.z], "right");
|
|
461
|
+
const fov = verticalFovRad(c.camera);
|
|
462
|
+
const pose = {
|
|
463
|
+
position,
|
|
464
|
+
direction,
|
|
465
|
+
...(fov !== undefined ? { fov } : {}),
|
|
466
|
+
};
|
|
467
|
+
if (suppressIdleSamples && lastPose && poseUnchanged(lastPose, pose, cameraEpsilon)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
lastPose = pose;
|
|
471
|
+
// Gaze: one camera-forward raycast per emitted pose (ADR 0030), computed
|
|
472
|
+
// AFTER the idle-dedup check so static frames cost nothing. PlayCanvas
|
|
473
|
+
// picks right-handed → normalize the hit to the canonical frame.
|
|
474
|
+
const gazeHit = gazeProbe?.();
|
|
475
|
+
const hitPoint = gazeHit
|
|
476
|
+
? toCanonicalPosition(gazeHit.point, "right")
|
|
477
|
+
: undefined;
|
|
478
|
+
const hitMesh = gazeHit && gazeHit.name ? gazeHit.name : undefined;
|
|
479
|
+
ctx.emit({
|
|
480
|
+
type: "camera_sample",
|
|
481
|
+
position: pose.position,
|
|
482
|
+
direction: pose.direction,
|
|
483
|
+
...(pose.fov !== undefined ? { fov: pose.fov } : {}),
|
|
484
|
+
...(hitPoint ? { hitPoint } : {}),
|
|
485
|
+
...(hitMesh ? { hitMesh } : {}),
|
|
486
|
+
});
|
|
487
|
+
};
|
|
488
|
+
// Snapshot the camera for navigation-gesture classification (ADR 0025).
|
|
489
|
+
// PlayCanvas `forward`/`up` are true world-space directions; it has no
|
|
490
|
+
// built-in orbit pivot (orbit scripts are external), so none is supplied and
|
|
491
|
+
// the classifier infers a pivot from the two view rays for orbit typing.
|
|
492
|
+
const readGestureSample = () => {
|
|
493
|
+
const c = camera;
|
|
494
|
+
const p = c.getPosition();
|
|
495
|
+
const f = c.forward;
|
|
496
|
+
const u = c.up;
|
|
497
|
+
const sample = {
|
|
498
|
+
position: toCanonicalPosition([p.x, p.y, p.z], "right"),
|
|
499
|
+
forward: toCanonicalDirection([f.x, f.y, f.z], "right"),
|
|
500
|
+
up: toCanonicalDirection([u.x, u.y, u.z], "right"),
|
|
501
|
+
};
|
|
502
|
+
const fov = verticalFovRad(c.camera);
|
|
503
|
+
if (fov !== undefined)
|
|
504
|
+
sample.fov = fov;
|
|
505
|
+
return sample;
|
|
506
|
+
};
|
|
507
|
+
const samplePerf = () => {
|
|
508
|
+
const fps = readFps();
|
|
509
|
+
if (fps <= 0)
|
|
510
|
+
return;
|
|
511
|
+
if (suppressIdlePerfSamples &&
|
|
512
|
+
lastFps !== undefined &&
|
|
513
|
+
Math.abs(fps - lastFps) <= perfFpsThreshold) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
lastFps = fps;
|
|
517
|
+
ctx.emit({ type: "frame_perf", fps });
|
|
518
|
+
};
|
|
519
|
+
// Drive a continuous channel either on a timer (fixed interval) or once per
|
|
520
|
+
// engine frame ("frame"). PlayCanvas owns its render loop, so the connector
|
|
521
|
+
// subscribes to its `frameend` event and fans out to the registered frame
|
|
522
|
+
// callbacks; the subscription is removed on stop.
|
|
523
|
+
const tickFrame = () => {
|
|
524
|
+
if (disposed)
|
|
525
|
+
return;
|
|
526
|
+
for (const cb of frameCallbacks)
|
|
527
|
+
cb();
|
|
528
|
+
};
|
|
529
|
+
const ensureFrameendBound = () => {
|
|
530
|
+
if (!frameendBound) {
|
|
531
|
+
appView.on("frameend", tickFrame);
|
|
532
|
+
frameendBound = true;
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
const driveChannel = (cadence, sample) => {
|
|
536
|
+
if (cadence.mode === "interval") {
|
|
537
|
+
timers.push(setInterval(sample, cadence.ms));
|
|
538
|
+
}
|
|
539
|
+
else if (cadence.mode === "frame") {
|
|
540
|
+
frameCallbacks.push(sample);
|
|
541
|
+
ensureFrameendBound();
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
if (want.camera) {
|
|
545
|
+
sampleCamera();
|
|
546
|
+
driveChannel(cameraCadence, sampleCamera);
|
|
547
|
+
}
|
|
548
|
+
if (want.perf) {
|
|
549
|
+
driveChannel(perfCadence, samplePerf);
|
|
550
|
+
}
|
|
551
|
+
if (wantNodes) {
|
|
552
|
+
// Scene-actor capture (`node_transform`, ADR 0027 Tier 1). Each declared
|
|
553
|
+
// actor gets its own cadence-driven sampler: resolve the node (lazily —
|
|
554
|
+
// resolvers handle load order), refuse cameras (the visitor camera is
|
|
555
|
+
// already `camera_sample`; "events live once"), read the WORLD transform,
|
|
556
|
+
// convert it to the canonical frame, and emit. Idle suppression skips
|
|
557
|
+
// samples where the transform is unchanged so a static actor costs nothing.
|
|
558
|
+
const root = appView.root;
|
|
559
|
+
const lastNodeSample = new Map();
|
|
560
|
+
const refusedCamera = new Set();
|
|
561
|
+
for (const id of actorIds) {
|
|
562
|
+
const actor = actorMap[id];
|
|
563
|
+
const entry = sampling.nodes?.[id];
|
|
564
|
+
const cadence = resolveCadence(nodeRate(entry), sampleCameraMs);
|
|
565
|
+
if (cadence.mode === "off")
|
|
566
|
+
continue;
|
|
567
|
+
// Tier-1 subtree (ADR 0033): when the entry declares an `include`, the
|
|
568
|
+
// actor stands in for a moving hierarchy and each captured descendant
|
|
569
|
+
// is emitted with a `childPath` relative to the actor.
|
|
570
|
+
const subtree = nodeSubtree(entry);
|
|
571
|
+
const sampleNode = () => {
|
|
572
|
+
const node = resolveActorNode(root, actor);
|
|
573
|
+
if (!node)
|
|
574
|
+
return;
|
|
575
|
+
if (isCameraEntity(node)) {
|
|
576
|
+
if (!refusedCamera.has(id)) {
|
|
577
|
+
refusedCamera.add(id);
|
|
578
|
+
console.warn(`[uptimizr] actor "${id}" resolves to a camera; refusing node_transform ` +
|
|
579
|
+
"capture. The visitor camera is already captured as camera_sample.");
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const sample = readNodeTransform(node, cameraEpsilon);
|
|
584
|
+
const prev = lastNodeSample.get(id);
|
|
585
|
+
if (!(suppressIdleSamples && prev && nodeSampleUnchanged(prev, sample, cameraEpsilon))) {
|
|
586
|
+
lastNodeSample.set(id, sample);
|
|
587
|
+
ctx.emit({
|
|
588
|
+
type: "node_transform",
|
|
589
|
+
nodeId: id,
|
|
590
|
+
position: sample.position,
|
|
591
|
+
rotation: sample.rotation,
|
|
592
|
+
...(sample.scale ? { scale: sample.scale } : {}),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
// Subtree descendants (ADR 0033): walk the bounded hierarchy and emit
|
|
596
|
+
// each kept node's WORLD transform with its `childPath`. Idle
|
|
597
|
+
// suppression is keyed per (actor, childPath) so a static part costs
|
|
598
|
+
// nothing on the wire.
|
|
599
|
+
if (subtree) {
|
|
600
|
+
for (const { childPath, node: child } of collectSubtree(node, subtree)) {
|
|
601
|
+
const childSample = readNodeTransform(child, cameraEpsilon);
|
|
602
|
+
const key = `${id}\u0000${childPath}`;
|
|
603
|
+
const childPrev = lastNodeSample.get(key);
|
|
604
|
+
if (suppressIdleSamples &&
|
|
605
|
+
childPrev &&
|
|
606
|
+
nodeSampleUnchanged(childPrev, childSample, cameraEpsilon)) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
lastNodeSample.set(key, childSample);
|
|
610
|
+
ctx.emit({
|
|
611
|
+
type: "node_transform",
|
|
612
|
+
nodeId: id,
|
|
613
|
+
childPath,
|
|
614
|
+
position: childSample.position,
|
|
615
|
+
rotation: childSample.rotation,
|
|
616
|
+
...(childSample.scale ? { scale: childSample.scale } : {}),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
sampleNode();
|
|
622
|
+
driveChannel(cadence, sampleNode);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (wantBones) {
|
|
626
|
+
// Skeleton bone capture (`node_transform` + `boneId`, ADR 0027 Tier 2).
|
|
627
|
+
// For each declared skinned actor, resolve its skeleton bones (by the
|
|
628
|
+
// configured allowlist or "*"), then sample each bone's skeleton-LOCAL
|
|
629
|
+
// pose at the actor's cadence. Per-bone idle suppression keeps a still
|
|
630
|
+
// rig free. The local frame is parent-relative, so motion replays onto a
|
|
631
|
+
// differently-placed instance of the same rig (ADR 0027).
|
|
632
|
+
const root = appView.root;
|
|
633
|
+
const lastBoneSample = new Map();
|
|
634
|
+
const warnedNoSkeleton = new Set();
|
|
635
|
+
for (const id of boneActorIds) {
|
|
636
|
+
const actor = actorMap[id];
|
|
637
|
+
const cfg = sampling.bones[id];
|
|
638
|
+
const cadence = resolveCadence(cfg.hz, sampleCameraMs);
|
|
639
|
+
if (cadence.mode === "off")
|
|
640
|
+
continue;
|
|
641
|
+
const sampleBones = () => {
|
|
642
|
+
const node = resolveActorNode(root, actor);
|
|
643
|
+
if (!node)
|
|
644
|
+
return;
|
|
645
|
+
const bones = resolvePlayCanvasBones(node, cfg.include);
|
|
646
|
+
if (bones.length === 0) {
|
|
647
|
+
if (!warnedNoSkeleton.has(id)) {
|
|
648
|
+
warnedNoSkeleton.add(id);
|
|
649
|
+
console.warn(`[uptimizr] actor "${id}" resolves to no matching skeleton bones; skipping ` +
|
|
650
|
+
"Tier 2 capture. Declare the skinned entity and check the bone names.");
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
for (const bone of bones) {
|
|
655
|
+
const boneName = bone.name;
|
|
656
|
+
if (typeof boneName !== "string")
|
|
657
|
+
continue;
|
|
658
|
+
const sample = readPlayCanvasBoneTransform(bone, cameraEpsilon);
|
|
659
|
+
if (!sample)
|
|
660
|
+
continue;
|
|
661
|
+
const key = `${id}\u0000${boneName}`;
|
|
662
|
+
const prev = lastBoneSample.get(key);
|
|
663
|
+
if (suppressIdleSamples && prev && nodeSampleUnchanged(prev, sample, cameraEpsilon)) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
lastBoneSample.set(key, sample);
|
|
667
|
+
ctx.emit({
|
|
668
|
+
type: "node_transform",
|
|
669
|
+
nodeId: id,
|
|
670
|
+
boneId: boneName,
|
|
671
|
+
position: sample.position,
|
|
672
|
+
rotation: sample.rotation,
|
|
673
|
+
...(sample.scale ? { scale: sample.scale } : {}),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
sampleBones();
|
|
678
|
+
driveChannel(cadence, sampleBones);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// --- Per-object dwell (`mesh_visibility`, #37) ---
|
|
682
|
+
// Accumulate on-screen / near-centre time per object every engine frame
|
|
683
|
+
// (frameend pauses when the tab is hidden — like Babylon's onBeforeRender),
|
|
684
|
+
// then flush one bucketed summary per object per window (ADR 0012).
|
|
685
|
+
if (want.meshVisibility) {
|
|
686
|
+
const accum = new Map();
|
|
687
|
+
// Last AABB sent per mesh, so a static box is sent once then suppressed.
|
|
688
|
+
const sentBounds = new Map();
|
|
689
|
+
const boundsEps = 1e-3;
|
|
690
|
+
let lastVisTime = ctx.now();
|
|
691
|
+
const sampleVisibility = () => {
|
|
692
|
+
const now = ctx.now();
|
|
693
|
+
const stepMs = now - lastVisTime;
|
|
694
|
+
lastVisTime = now;
|
|
695
|
+
if (stepMs <= 0)
|
|
696
|
+
return;
|
|
697
|
+
const camView = camera;
|
|
698
|
+
const cp = camView.getPosition();
|
|
699
|
+
const cf = camView.forward;
|
|
700
|
+
const camPos = [cp.x, cp.y, cp.z];
|
|
701
|
+
const forward = [cf.x, cf.y, cf.z];
|
|
702
|
+
const fwdLen = len3(forward) || 1;
|
|
703
|
+
// Half vertical FOV in radians; fall back for ortho / missing fov.
|
|
704
|
+
const vfov = verticalFovRad(camView.camera);
|
|
705
|
+
const halfFov = vfov !== undefined ? vfov / 2 : 0.4;
|
|
706
|
+
let tracked = 0;
|
|
707
|
+
appView.root?.forEach?.((raw) => {
|
|
708
|
+
const node = raw;
|
|
709
|
+
if (meshInstancesOf(node).length === 0)
|
|
710
|
+
return;
|
|
711
|
+
const name = node.name;
|
|
712
|
+
if (!name || name.startsWith("uptimizr-"))
|
|
713
|
+
return;
|
|
714
|
+
if (visMeshAllowlist) {
|
|
715
|
+
if (!visMeshAllowlist.has(name))
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
if (node.enabled === false)
|
|
720
|
+
return;
|
|
721
|
+
if (tracked >= visMaxMeshes)
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const bounds = readWorldBounds(node);
|
|
725
|
+
if (!bounds)
|
|
726
|
+
return;
|
|
727
|
+
tracked++;
|
|
728
|
+
if (!sphereInFront(bounds.center, bounds.radius, camPos, forward)) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const toCenter = sub3(bounds.center, camPos);
|
|
732
|
+
const dist = len3(toCenter) || 1e-6;
|
|
733
|
+
const cosAngle = dot3(toCenter, forward) / (dist * fwdLen);
|
|
734
|
+
const screenFraction = clamp01(Math.atan2(bounds.radius, dist) / (halfFov || 1e-6));
|
|
735
|
+
let entry = accum.get(name);
|
|
736
|
+
if (!entry) {
|
|
737
|
+
entry = { visibleMs: 0, centeredMs: 0, maxScreenFraction: 0 };
|
|
738
|
+
accum.set(name, entry);
|
|
739
|
+
}
|
|
740
|
+
entry.visibleMs += stepMs;
|
|
741
|
+
if (cosAngle >= visCenteredCos)
|
|
742
|
+
entry.centeredMs += stepMs;
|
|
743
|
+
if (screenFraction > entry.maxScreenFraction)
|
|
744
|
+
entry.maxScreenFraction = screenFraction;
|
|
745
|
+
if (visBoundingBox) {
|
|
746
|
+
// Canonical frame negates Z (right-handed PlayCanvas → canonical),
|
|
747
|
+
// which swaps the Z min/max — match hitPoint / camera_sample (ADR 0018).
|
|
748
|
+
const [aMinX, aMinY, aMinZ, aMaxX, aMaxY, aMaxZ] = bounds.aabb;
|
|
749
|
+
entry.bounds = [aMinX, aMinY, -aMaxZ, aMaxX, aMaxY, -aMinZ];
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
const flushVisibility = () => {
|
|
754
|
+
for (const [mesh, entry] of accum) {
|
|
755
|
+
if (entry.visibleMs <= 0)
|
|
756
|
+
continue;
|
|
757
|
+
let bounds;
|
|
758
|
+
if (entry.bounds) {
|
|
759
|
+
const rounded = roundAabb(entry.bounds);
|
|
760
|
+
const prev = sentBounds.get(mesh);
|
|
761
|
+
if (!prev || !aabbClose(prev, rounded, boundsEps)) {
|
|
762
|
+
bounds = rounded;
|
|
763
|
+
sentBounds.set(mesh, rounded);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
ctx.emit({
|
|
767
|
+
type: "mesh_visibility",
|
|
768
|
+
mesh,
|
|
769
|
+
visibleMs: Math.round(entry.visibleMs),
|
|
770
|
+
...(entry.centeredMs > 0 ? { centeredMs: Math.round(entry.centeredMs) } : {}),
|
|
771
|
+
...(entry.maxScreenFraction > 0
|
|
772
|
+
? { maxScreenFraction: entry.maxScreenFraction }
|
|
773
|
+
: {}),
|
|
774
|
+
...(bounds ? { bounds } : {}),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
accum.clear();
|
|
778
|
+
};
|
|
779
|
+
// Sample every frame; flush on the window timer + once on stop (trailing).
|
|
780
|
+
frameCallbacks.push(sampleVisibility);
|
|
781
|
+
ensureFrameendBound();
|
|
782
|
+
timers.push(setInterval(flushVisibility, visWindowMs));
|
|
783
|
+
stopCallbacks.push(flushVisibility);
|
|
784
|
+
}
|
|
785
|
+
// --- Pointer / raycast wiring (DOM listeners on the device canvas) ---
|
|
786
|
+
const wantPointer = want.pointerMove || want.clicks || want.buttons || want.meshPicks || want.hoverDwell;
|
|
787
|
+
const canvas = appView.graphicsDevice?.canvas;
|
|
788
|
+
const raycast = wantPointer && canvas ? (options.raycast ?? createSceneRaycaster(app, camera)) : undefined;
|
|
789
|
+
const addListener = (type, handler) => {
|
|
790
|
+
if (!canvas)
|
|
791
|
+
return;
|
|
792
|
+
canvas.addEventListener(type, handler);
|
|
793
|
+
domListeners.push({ target: canvas, type, handler });
|
|
794
|
+
};
|
|
795
|
+
const screenOf = (ev) => {
|
|
796
|
+
// Pointer Lock (ADR 0034): the OS cursor is frozen and the crosshair is the
|
|
797
|
+
// viewport centre, so report centre; `pickAt` then raycasts from centre.
|
|
798
|
+
if (isPointerLocked(() => canvas))
|
|
799
|
+
return [0.5, 0.5];
|
|
800
|
+
const rect = canvas && typeof canvas.getBoundingClientRect === "function"
|
|
801
|
+
? canvas.getBoundingClientRect()
|
|
802
|
+
: { left: 0, top: 0, width: 0, height: 0 };
|
|
803
|
+
const w = rect.width || 1;
|
|
804
|
+
const h = rect.height || 1;
|
|
805
|
+
// Normalized, origin top-left, clamped to [0,1] — engine-independent.
|
|
806
|
+
return [clamp01((ev.clientX - rect.left) / w), clamp01((ev.clientY - rect.top) / h)];
|
|
807
|
+
};
|
|
808
|
+
const pickAt = (screen) => {
|
|
809
|
+
if (!raycast)
|
|
810
|
+
return undefined;
|
|
811
|
+
// Screen [0,1] (top-left) → NDC [-1,1] (y-up) for the raycaster.
|
|
812
|
+
const ndcX = screen[0] * 2 - 1;
|
|
813
|
+
const ndcY = 1 - screen[1] * 2;
|
|
814
|
+
return raycast(ndcX, ndcY);
|
|
815
|
+
};
|
|
816
|
+
const buildBase = (ev) => {
|
|
817
|
+
const screen = screenOf(ev);
|
|
818
|
+
const hit = pickAt(screen);
|
|
819
|
+
// Hit point is in PlayCanvas' right-handed world frame; normalize to canonical.
|
|
820
|
+
const hitPoint = hit ? toCanonicalPosition(hit.point, "right") : undefined;
|
|
821
|
+
const hitMesh = hit?.name ? hit.name : undefined;
|
|
822
|
+
const source = pointerSource(ev);
|
|
823
|
+
return {
|
|
824
|
+
screen,
|
|
825
|
+
...(hitPoint ? { hitPoint } : {}),
|
|
826
|
+
...(hitMesh ? { hitMesh } : {}),
|
|
827
|
+
...(source ? { source } : {}),
|
|
828
|
+
...(hit ? { hit } : {}),
|
|
829
|
+
};
|
|
830
|
+
};
|
|
831
|
+
// --- Hover hesitation (`hover_dwell`, #48) episode state ---
|
|
832
|
+
// An episode runs while the pointer rests on one object. It is reported only
|
|
833
|
+
// if it lasted >= minDwellMs AND the user never acted on the object (a click
|
|
834
|
+
// means deliberate engagement, not hesitation).
|
|
835
|
+
let hoverMesh;
|
|
836
|
+
let hoverStartMs = 0;
|
|
837
|
+
let hoverActed = false;
|
|
838
|
+
let hoverSource;
|
|
839
|
+
const flushHover = (now) => {
|
|
840
|
+
if (hoverMesh !== undefined && !hoverActed) {
|
|
841
|
+
const dwellMs = now - hoverStartMs;
|
|
842
|
+
if (dwellMs >= hoverMinDwellMs) {
|
|
843
|
+
ctx.emit({
|
|
844
|
+
type: "hover_dwell",
|
|
845
|
+
mesh: hoverMesh,
|
|
846
|
+
dwellMs,
|
|
847
|
+
...(hoverSource ? { source: hoverSource } : {}),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
hoverMesh = undefined;
|
|
852
|
+
hoverActed = false;
|
|
853
|
+
hoverSource = undefined;
|
|
854
|
+
};
|
|
855
|
+
const trackHover = (now, mesh, source) => {
|
|
856
|
+
// Only track allowlisted meshes (or all, when no allowlist is given).
|
|
857
|
+
const target = mesh !== undefined && (!hoverMeshAllowlist || hoverMeshAllowlist.has(mesh))
|
|
858
|
+
? mesh
|
|
859
|
+
: undefined;
|
|
860
|
+
if (target === hoverMesh)
|
|
861
|
+
return;
|
|
862
|
+
flushHover(now);
|
|
863
|
+
if (target !== undefined) {
|
|
864
|
+
hoverMesh = target;
|
|
865
|
+
hoverStartMs = now;
|
|
866
|
+
hoverActed = false;
|
|
867
|
+
hoverSource = source;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const onPointerMove = (raw) => {
|
|
871
|
+
const ev = raw;
|
|
872
|
+
// Hover tracking needs a per-move pick and runs before the pointer-move
|
|
873
|
+
// throttle so an episode boundary is never missed.
|
|
874
|
+
if (want.hoverDwell) {
|
|
875
|
+
const base = buildBase(ev);
|
|
876
|
+
trackHover(ctx.now(), base.hitMesh, base.source);
|
|
877
|
+
if (want.pointerMove) {
|
|
878
|
+
const now = ctx.now();
|
|
879
|
+
if (now - lastPointerMove >= pointerThrottleMs) {
|
|
880
|
+
lastPointerMove = now;
|
|
881
|
+
ctx.emit({
|
|
882
|
+
type: "pointer_move",
|
|
883
|
+
screen: base.screen,
|
|
884
|
+
...(base.hitPoint ? { hitPoint: base.hitPoint } : {}),
|
|
885
|
+
...(base.hitMesh ? { hitMesh: base.hitMesh } : {}),
|
|
886
|
+
...(base.source ? { source: base.source } : {}),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!want.pointerMove)
|
|
893
|
+
return;
|
|
894
|
+
const now = ctx.now();
|
|
895
|
+
if (now - lastPointerMove < pointerThrottleMs)
|
|
896
|
+
return;
|
|
897
|
+
lastPointerMove = now;
|
|
898
|
+
const { screen, hitPoint, hitMesh, source } = buildBase(ev);
|
|
899
|
+
ctx.emit({
|
|
900
|
+
type: "pointer_move",
|
|
901
|
+
screen,
|
|
902
|
+
...(hitPoint ? { hitPoint } : {}),
|
|
903
|
+
...(hitMesh ? { hitMesh } : {}),
|
|
904
|
+
...(source ? { source } : {}),
|
|
905
|
+
});
|
|
906
|
+
};
|
|
907
|
+
const emitButton = (type, raw) => {
|
|
908
|
+
const ev = raw;
|
|
909
|
+
if (type === "pointer_down" && want.hoverDwell) {
|
|
910
|
+
const { hitMesh } = buildBase(ev);
|
|
911
|
+
if (hitMesh !== undefined && hitMesh === hoverMesh)
|
|
912
|
+
hoverActed = true;
|
|
913
|
+
}
|
|
914
|
+
if (!want.buttons)
|
|
915
|
+
return;
|
|
916
|
+
const { screen, hitPoint, hitMesh, source } = buildBase(ev);
|
|
917
|
+
ctx.emit({
|
|
918
|
+
type,
|
|
919
|
+
screen,
|
|
920
|
+
...(hitPoint ? { hitPoint } : {}),
|
|
921
|
+
...(hitMesh ? { hitMesh } : {}),
|
|
922
|
+
...(typeof ev.button === "number" ? { button: ev.button } : {}),
|
|
923
|
+
...(source ? { source } : {}),
|
|
924
|
+
});
|
|
925
|
+
};
|
|
926
|
+
const onClick = (raw) => {
|
|
927
|
+
const ev = raw;
|
|
928
|
+
const { screen, hitPoint, hitMesh, source, hit } = buildBase(ev);
|
|
929
|
+
// A click on the hovered object marks the episode as acted-on (suppressed).
|
|
930
|
+
if (want.hoverDwell && hitMesh !== undefined && hitMesh === hoverMesh) {
|
|
931
|
+
hoverActed = true;
|
|
932
|
+
}
|
|
933
|
+
if (want.clicks) {
|
|
934
|
+
ctx.emit({
|
|
935
|
+
type: "pointer_click",
|
|
936
|
+
screen,
|
|
937
|
+
...(hitPoint ? { hitPoint } : {}),
|
|
938
|
+
...(hitMesh ? { hitMesh } : {}),
|
|
939
|
+
...(typeof ev.button === "number" ? { button: ev.button } : {}),
|
|
940
|
+
...(source ? { source } : {}),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
if (want.meshPicks && hit) {
|
|
944
|
+
ctx.emit({
|
|
945
|
+
type: "mesh_interaction",
|
|
946
|
+
mesh: hit.name,
|
|
947
|
+
kind: "pick",
|
|
948
|
+
...(hitPoint ? { point: hitPoint } : {}),
|
|
949
|
+
...(source ? { source } : {}),
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
if (want.pointerMove || want.hoverDwell)
|
|
954
|
+
addListener("pointermove", onPointerMove);
|
|
955
|
+
if (want.buttons || want.hoverDwell) {
|
|
956
|
+
addListener("pointerdown", (e) => emitButton("pointer_down", e));
|
|
957
|
+
}
|
|
958
|
+
if (want.buttons) {
|
|
959
|
+
addListener("pointerup", (e) => emitButton("pointer_up", e));
|
|
960
|
+
}
|
|
961
|
+
if (want.clicks || want.meshPicks || want.hoverDwell)
|
|
962
|
+
addListener("click", onClick);
|
|
963
|
+
// camera_gesture (ADR 0025): bracket the press and classify the viewpoint
|
|
964
|
+
// change between down and up. No raycast and no mesh — a navigation gesture
|
|
965
|
+
// is not an object interaction. PlayCanvas has no built-in orbit pivot, so the
|
|
966
|
+
// classifier infers one from the two view rays for orbit typing.
|
|
967
|
+
if (want.cameraGesture) {
|
|
968
|
+
const onGestureDown = (raw) => {
|
|
969
|
+
gestureStart = {
|
|
970
|
+
sample: readGestureSample(),
|
|
971
|
+
ts: ctx.now(),
|
|
972
|
+
source: pointerSource(raw),
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
const onGestureUp = () => {
|
|
976
|
+
const opened = gestureStart;
|
|
977
|
+
gestureStart = null;
|
|
978
|
+
if (!opened)
|
|
979
|
+
return;
|
|
980
|
+
const classified = classifyCameraGesture(opened.sample, readGestureSample(), {
|
|
981
|
+
sensitivity: cameraGestureSensitivity,
|
|
982
|
+
});
|
|
983
|
+
if (!classified)
|
|
984
|
+
return;
|
|
985
|
+
ctx.emit({
|
|
986
|
+
type: "camera_gesture",
|
|
987
|
+
kind: classified.kind,
|
|
988
|
+
durationMs: Math.max(0, Math.round(ctx.now() - opened.ts)),
|
|
989
|
+
...(classified.orbitDeg !== undefined ? { orbitDeg: classified.orbitDeg } : {}),
|
|
990
|
+
...(classified.rollDeg !== undefined ? { rollDeg: classified.rollDeg } : {}),
|
|
991
|
+
...(classified.zoomRatio !== undefined ? { zoomRatio: classified.zoomRatio } : {}),
|
|
992
|
+
...(classified.panDist !== undefined ? { panDist: classified.panDist } : {}),
|
|
993
|
+
...(opened.source ? { source: opened.source } : {}),
|
|
994
|
+
});
|
|
995
|
+
};
|
|
996
|
+
addListener("pointerdown", onGestureDown);
|
|
997
|
+
addListener("pointerup", onGestureUp);
|
|
998
|
+
}
|
|
999
|
+
// Trailing flush: report an in-progress hover episode on stop.
|
|
1000
|
+
if (want.hoverDwell)
|
|
1001
|
+
stopCallbacks.push(() => flushHover(ctx.now()));
|
|
1002
|
+
// Engine GPU context loss/restore. Babylon exposes engine observables;
|
|
1003
|
+
// PlayCanvas surfaces them as DOM events on the WebGL canvas. Each emits a
|
|
1004
|
+
// discrete lifecycle event so the timeline records rendering interruptions.
|
|
1005
|
+
if (want.contextLoss) {
|
|
1006
|
+
addListener("webglcontextlost", () => ctx.emit({ type: "context_lost" }));
|
|
1007
|
+
addListener("webglcontextrestored", () => ctx.emit({ type: "context_restored" }));
|
|
1008
|
+
}
|
|
1009
|
+
// GPU / memory footprint (`resource_sample`, #44). A low-rate timer samples
|
|
1010
|
+
// the triangles PlayCanvas submitted last frame (`app.stats.frame.triangles`)
|
|
1011
|
+
// and the JS heap. PlayCanvas exposes no per-frame vertex count or resident
|
|
1012
|
+
// texture/geometry bytes on its public surface, so those are omitted; only
|
|
1013
|
+
// defined metrics are emitted (the aggregate's NULLIF keeps absent metrics
|
|
1014
|
+
// out of the averages).
|
|
1015
|
+
if (want.resourceSample) {
|
|
1016
|
+
const sampleResources = () => {
|
|
1017
|
+
const sample = {};
|
|
1018
|
+
const tris = appView.stats?.frame?.triangles;
|
|
1019
|
+
if (typeof tris === "number" && tris > 0)
|
|
1020
|
+
sample.triangles = Math.round(tris);
|
|
1021
|
+
// Chromium-only: performance.memory.usedJSHeapSize. Absent elsewhere.
|
|
1022
|
+
const mem = globalThis.performance?.memory;
|
|
1023
|
+
if (mem && typeof mem.usedJSHeapSize === "number" && mem.usedJSHeapSize > 0) {
|
|
1024
|
+
sample.jsHeapBytes = mem.usedJSHeapSize;
|
|
1025
|
+
}
|
|
1026
|
+
// Nothing measurable this tick (e.g. before the first render).
|
|
1027
|
+
if (Object.keys(sample).length === 0)
|
|
1028
|
+
return;
|
|
1029
|
+
ctx.emit({ type: "resource_sample", ...sample });
|
|
1030
|
+
};
|
|
1031
|
+
timers.push(setInterval(sampleResources, resourceIntervalMs));
|
|
1032
|
+
}
|
|
1033
|
+
// Keyboard `input_action` capture (ADR 0023). PlayCanvas exposes no keyboard
|
|
1034
|
+
// observable here, so listen on `window` (the canvas rarely holds focus in
|
|
1035
|
+
// pointer-lock / FPS scenes). Only allowlisted keys are recorded: the
|
|
1036
|
+
// physical `code` is looked up and the mapped semantic action emitted.
|
|
1037
|
+
// Auto-repeat keydowns are dropped so a held key fires once; unbound keys
|
|
1038
|
+
// are ignored, so arbitrary typing is never seen.
|
|
1039
|
+
if (want.keyboard && typeof window !== "undefined") {
|
|
1040
|
+
const target = window;
|
|
1041
|
+
const onKey = (pressed) => (raw) => {
|
|
1042
|
+
const ev = raw;
|
|
1043
|
+
const code = ev.code;
|
|
1044
|
+
if (!code || (pressed && ev.repeat))
|
|
1045
|
+
return;
|
|
1046
|
+
const action = keyBindings[code];
|
|
1047
|
+
if (!action)
|
|
1048
|
+
return;
|
|
1049
|
+
ctx.trackInput(action, { source: "keyboard", code, pressed });
|
|
1050
|
+
};
|
|
1051
|
+
const downHandler = onKey(true);
|
|
1052
|
+
const upHandler = onKey(false);
|
|
1053
|
+
target.addEventListener("keydown", downHandler);
|
|
1054
|
+
target.addEventListener("keyup", upHandler);
|
|
1055
|
+
domListeners.push({ target, type: "keydown", handler: downHandler });
|
|
1056
|
+
domListeners.push({ target, type: "keyup", handler: upHandler });
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
stop() {
|
|
1060
|
+
// Run trailing flushes (windowed/episodic captures) before teardown.
|
|
1061
|
+
for (const cb of stopCallbacks)
|
|
1062
|
+
cb();
|
|
1063
|
+
stopCallbacks.length = 0;
|
|
1064
|
+
disposed = true;
|
|
1065
|
+
for (const t of timers)
|
|
1066
|
+
clearInterval(t);
|
|
1067
|
+
timers.length = 0;
|
|
1068
|
+
if (frameendBound) {
|
|
1069
|
+
appView.off("frameend", tickFrame);
|
|
1070
|
+
frameendBound = false;
|
|
1071
|
+
}
|
|
1072
|
+
frameCallbacks.length = 0;
|
|
1073
|
+
for (const l of domListeners)
|
|
1074
|
+
l.target.removeEventListener(l.type, l.handler);
|
|
1075
|
+
domListeners.length = 0;
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
//# sourceMappingURL=collector.js.map
|