@playcanvas/web-components 0.9.0 → 0.10.1

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.
Files changed (74) hide show
  1. package/README.md +18 -0
  2. package/dist/app.d.ts +55 -9
  3. package/dist/asset.d.ts +25 -1
  4. package/dist/async-element.d.ts +15 -2
  5. package/dist/components/button-component.d.ts +1 -1
  6. package/dist/components/camera-component.d.ts +1 -1
  7. package/dist/components/collision-component.d.ts +1 -1
  8. package/dist/components/component.d.ts +6 -5
  9. package/dist/components/element-component.d.ts +1 -1
  10. package/dist/components/gsplat-component.d.ts +1 -1
  11. package/dist/components/layoutchild-component.d.ts +1 -1
  12. package/dist/components/layoutgroup-component.d.ts +1 -1
  13. package/dist/components/light-component.d.ts +1 -1
  14. package/dist/components/particlesystem-component.d.ts +1 -1
  15. package/dist/components/render-component.d.ts +1 -1
  16. package/dist/components/rigidbody-component.d.ts +1 -1
  17. package/dist/components/screen-component.d.ts +1 -1
  18. package/dist/components/script.d.ts +8 -1
  19. package/dist/components/scrollbar-component.d.ts +1 -1
  20. package/dist/components/scrollview-component.d.ts +1 -1
  21. package/dist/components/sound-component.d.ts +1 -1
  22. package/dist/components/sound-slot.d.ts +9 -1
  23. package/dist/custom-elements.json +16704 -0
  24. package/dist/entity.d.ts +24 -5
  25. package/dist/loading-bar.d.ts +35 -0
  26. package/dist/material.d.ts +972 -4
  27. package/dist/model.d.ts +1 -1
  28. package/dist/module.d.ts +10 -0
  29. package/dist/{utils.d.ts → parse.d.ts} +63 -33
  30. package/dist/pwc.cjs +3070 -699
  31. package/dist/pwc.cjs.map +1 -1
  32. package/dist/pwc.js +3070 -699
  33. package/dist/pwc.js.map +1 -1
  34. package/dist/pwc.min.js +1 -1
  35. package/dist/pwc.min.js.map +1 -1
  36. package/dist/pwc.min.mjs +2 -0
  37. package/dist/pwc.min.mjs.map +1 -0
  38. package/dist/pwc.mjs +3071 -700
  39. package/dist/pwc.mjs.map +1 -1
  40. package/dist/scene.d.ts +12 -4
  41. package/dist/sky.d.ts +1 -1
  42. package/dist/vscode.html-custom-data.json +1800 -0
  43. package/dist/web-types.json +3836 -0
  44. package/package.json +29 -11
  45. package/src/app.ts +178 -78
  46. package/src/asset.ts +44 -2
  47. package/src/async-element.ts +17 -4
  48. package/src/components/button-component.ts +6 -6
  49. package/src/components/camera-component.ts +2 -2
  50. package/src/components/collision-component.ts +2 -2
  51. package/src/components/component.ts +8 -7
  52. package/src/components/element-component.ts +6 -6
  53. package/src/components/gsplat-component.ts +3 -3
  54. package/src/components/layoutchild-component.ts +2 -2
  55. package/src/components/layoutgroup-component.ts +2 -2
  56. package/src/components/light-component.ts +2 -2
  57. package/src/components/particlesystem-component.ts +2 -2
  58. package/src/components/render-component.ts +10 -5
  59. package/src/components/rigidbody-component.ts +2 -2
  60. package/src/components/screen-component.ts +2 -2
  61. package/src/components/script-component.ts +4 -4
  62. package/src/components/script.ts +9 -2
  63. package/src/components/scrollbar-component.ts +3 -3
  64. package/src/components/scrollview-component.ts +6 -6
  65. package/src/components/sound-component.ts +2 -2
  66. package/src/components/sound-slot.ts +31 -9
  67. package/src/entity.ts +56 -22
  68. package/src/loading-bar.ts +122 -0
  69. package/src/material.ts +2402 -59
  70. package/src/model.ts +2 -2
  71. package/src/module.ts +10 -0
  72. package/src/{utils.ts → parse.ts} +104 -65
  73. package/src/scene.ts +51 -21
  74. package/src/sky.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcanvas/web-components",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "author": "PlayCanvas <support@playcanvas.com>",
5
5
  "homepage": "https://playcanvas.com",
6
6
  "description": "Web Components for the PlayCanvas Engine",
@@ -25,8 +25,11 @@
25
25
  },
26
26
  "main": "dist/pwc.cjs",
27
27
  "module": "dist/pwc.mjs",
28
- "browser": "dist/pwc.js",
28
+ "unpkg": "dist/pwc.min.js",
29
+ "jsdelivr": "dist/pwc.min.js",
29
30
  "types": "dist/index.d.ts",
31
+ "customElements": "dist/custom-elements.json",
32
+ "web-types": "dist/web-types.json",
30
33
  "exports": {
31
34
  ".": {
32
35
  "types": "./dist/index.d.ts",
@@ -44,21 +47,31 @@
44
47
  "scripts": {
45
48
  "prebuild": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
46
49
  "build": "rollup -c",
50
+ "postbuild": "npm run cem",
51
+ "cem": "cem analyze && node utils/cem/validate.mjs",
47
52
  "dev": "concurrently \"npm run watch\" \"npm run serve\"",
48
53
  "docs": "typedoc",
49
- "lint": "eslint examples/js examples/assets/scripts src",
54
+ "lint": "eslint examples/js examples/assets/scripts src test vitest.config.ts",
50
55
  "serve": "serve",
51
- "test": "echo \"Error: no test specified\" && exit 1",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "test:coverage": "vitest run --coverage",
59
+ "test:unit": "vitest run --project unit",
60
+ "test:elements": "vitest run --project elements",
61
+ "test:integration": "vitest run --project integration",
52
62
  "publint": "publint",
53
- "type-check": "tsc --noEmit",
54
- "type-check:watch": "npm run type-check -- --watch",
63
+ "type-check": "npm run type-check:src && npm run type-check:test",
64
+ "type-check:src": "tsc --noEmit -p tsconfig.json",
65
+ "type-check:test": "tsc --noEmit -p tsconfig.test.json",
66
+ "type-check:watch": "npm run type-check:src -- --watch",
55
67
  "watch": "rollup -c -w"
56
68
  },
57
69
  "peerDependencies": {
58
70
  "playcanvas": "^2.20.1"
59
71
  },
60
72
  "devDependencies": {
61
- "@mediapipe/tasks-vision": "1.0.0",
73
+ "@custom-elements-manifest/analyzer": "0.11.0",
74
+ "@mediapipe/tasks-vision": "1.0.1",
62
75
  "@playcanvas/eslint-config": "2.1.0",
63
76
  "@rollup/plugin-commonjs": "29.0.3",
64
77
  "@rollup/plugin-node-resolve": "16.0.3",
@@ -67,20 +80,25 @@
67
80
  "@tweenjs/tween.js": "25.0.0",
68
81
  "@typescript-eslint/eslint-plugin": "8.65.0",
69
82
  "@typescript-eslint/parser": "8.65.0",
83
+ "@vitest/coverage-v8": "4.1.10",
70
84
  "concurrently": "10.0.4",
85
+ "custom-element-jet-brains-integration": "1.7.0",
86
+ "custom-element-vs-code-integration": "1.5.0",
71
87
  "earcut": "3.2.3",
72
88
  "eslint": "9.39.5",
73
89
  "eslint-import-resolver-typescript": "4.4.5",
74
- "globals": "17.8.0",
75
- "mediabunny": "1.51.0",
90
+ "globals": "17.9.0",
91
+ "jsdom": "30.0.1",
92
+ "mediabunny": "1.52.2",
76
93
  "opentype.js": "2.0.0",
77
94
  "playcanvas": "2.21.3",
78
95
  "publint": "0.3.22",
79
- "rollup": "4.62.3",
96
+ "rollup": "4.62.4",
80
97
  "serve": "14.2.6",
81
98
  "tslib": "2.8.1",
82
99
  "typedoc": "0.28.20",
83
100
  "typedoc-plugin-mdn-links": "5.1.1",
84
- "typescript": "6.0.3"
101
+ "typescript": "6.0.3",
102
+ "vitest": "4.1.10"
85
103
  }
86
104
  }
package/src/app.ts CHANGED
@@ -69,15 +69,21 @@ import {
69
69
  import { AssetElement } from './asset';
70
70
  import { AsyncElement } from './async-element';
71
71
  import { EntityElement } from './entity';
72
+ import { LoadingBar } from './loading-bar';
72
73
  import { MaterialElement } from './material';
73
74
  import { ModuleElement } from './module';
74
- import { parseBool, parseEnum } from './utils';
75
+ import { parseBool, parseEnum } from './parse';
75
76
 
76
77
  /**
77
78
  * The AppElement interface provides properties and methods for manipulating
78
79
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
79
80
  * The AppElement interface also inherits the properties and methods of the
80
81
  * {@link HTMLElement} interface.
82
+ *
83
+ * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
84
+ * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
85
+ * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
86
+ * not bubble.
81
87
  */
82
88
  class AppElement extends AsyncElement {
83
89
  /**
@@ -87,7 +93,7 @@ class AppElement extends AsyncElement {
87
93
 
88
94
  private _alpha = true;
89
95
 
90
- private _backend: 'webgpu' | 'webgl2' | 'null' = 'webgl2';
96
+ private _backend: 'webgpu' | 'webgl2' | 'null' = 'webgpu';
91
97
 
92
98
  private _antialias = true;
93
99
 
@@ -97,6 +103,10 @@ class AppElement extends AsyncElement {
97
103
 
98
104
  private _highResolution = true;
99
105
 
106
+ private _loadingBar = true;
107
+
108
+ private _bar: LoadingBar | null = null;
109
+
100
110
  private _hierarchyReady = false;
101
111
 
102
112
  private _picker: Picker | null = null;
@@ -111,6 +121,9 @@ class AppElement extends AsyncElement {
111
121
 
112
122
  private _hoveredEntity: EntityElement | null = null;
113
123
 
124
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
125
+ private _pickToken = 0;
126
+
114
127
  private _pointerHandlers: { [key: string]: EventListener | null } = {
115
128
  pointermove: null,
116
129
  pointerdown: null,
@@ -119,13 +132,27 @@ class AppElement extends AsyncElement {
119
132
 
120
133
  private _app: AppBase | null = null;
121
134
 
135
+ private _loadProgress = 0;
136
+
122
137
  /**
123
- * The PlayCanvas application instance. Available once the element is ready await
124
- * {@link whenReady} or the element's `ready()` promise before accessing it.
125
- * @returns The application instance.
138
+ * The PlayCanvas application instance. `null` until the element is ready, and again once it
139
+ * has been removed from the document — await {@link whenReady} or the element's `ready()`
140
+ * promise before accessing it.
141
+ * @returns The application instance, or `null`.
126
142
  */
127
- get app(): AppBase {
128
- return this._app!;
143
+ get app(): AppBase | null {
144
+ return this._app;
145
+ }
146
+
147
+ /**
148
+ * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
149
+ * preloading begins (and again once the element has been removed from the document), and 1
150
+ * once preloading has finished — including when there was nothing to preload. Read this to
151
+ * initialize a loading UI; subsequent updates arrive via the `progress` event.
152
+ * @returns The preload progress.
153
+ */
154
+ get loadProgress(): number {
155
+ return this._loadProgress;
129
156
  }
130
157
 
131
158
  /**
@@ -141,6 +168,12 @@ class AppElement extends AsyncElement {
141
168
  }
142
169
 
143
170
  async connectedCallback() {
171
+ // Created before the first await, so the bar is visible while modules and the graphics
172
+ // device are created, and exists before any disconnect could need to clean it up
173
+ if (this._loadingBar && !this._bar) {
174
+ this._bar = new LoadingBar(this);
175
+ }
176
+
144
177
  // Get all pc-module elements that are direct children of the pc-app element
145
178
  const moduleElements = this.querySelectorAll<ModuleElement>(':scope > pc-module');
146
179
 
@@ -234,11 +267,12 @@ class AppElement extends AsyncElement {
234
267
  createOptions.batchManager = BatchManager;
235
268
  createOptions.xr = XrManager;
236
269
 
237
- this._app = new AppBase(this._canvas);
238
- this.app.init(createOptions);
270
+ const app = new AppBase(this._canvas);
271
+ this._app = app;
272
+ app.init(createOptions);
239
273
 
240
- this.app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
241
- this.app.setCanvasResolution(RESOLUTION_AUTO);
274
+ app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
275
+ app.setCanvasResolution(RESOLUTION_AUTO);
242
276
 
243
277
  this._pickerCreate();
244
278
 
@@ -248,7 +282,7 @@ class AppElement extends AsyncElement {
248
282
  assetElement.createAsset();
249
283
  const asset = assetElement.asset;
250
284
  if (asset) {
251
- this.app!.assets.add(asset);
285
+ app.assets.add(asset);
252
286
  }
253
287
  });
254
288
 
@@ -261,20 +295,46 @@ class AppElement extends AsyncElement {
261
295
  // Create all entities
262
296
  const entityElements = this.querySelectorAll<EntityElement>('pc-entity');
263
297
  Array.from(entityElements).forEach((entityElement) => {
264
- entityElement.createEntity(this.app!);
298
+ entityElement.createEntity(app);
265
299
  });
266
300
 
267
301
  // Build hierarchy
268
302
  entityElements.forEach((entityElement) => {
269
- entityElement.buildHierarchy(this.app!);
303
+ entityElement.buildHierarchy(app);
270
304
  });
271
305
 
272
306
  this._hierarchyReady = true;
273
307
 
308
+ // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
309
+ // listener must be attached before preload() is called: an asset that is already loaded
310
+ // ticks synchronously inside it.
311
+ const total = app.assets.list({ preload: true }).length;
312
+ let loaded = 0;
313
+ const onPreloadProgress = () => {
314
+ loaded += 1;
315
+ this._loadProgress = loaded / total;
316
+ this._bar?.progress(loaded, total);
317
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
318
+ };
319
+ app.on('preload:progress', onPreloadProgress);
320
+
321
+ this._loadProgress = total === 0 ? 1 : 0;
322
+ this._bar?.progress(0, total);
323
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
324
+
274
325
  // Load assets before starting the application
275
- this.app.preload(() => {
326
+ app.preload(() => {
327
+ // Scope the counter to this preload pass, so a later app.preload() call by user code
328
+ // cannot push `loaded` past `total`
329
+ app.off('preload:progress', onPreloadProgress);
330
+ this._loadProgress = 1;
331
+
276
332
  // Start the application
277
- this.app!.start();
333
+ app.start();
334
+
335
+ // Dismiss the bar only once a frame has actually rendered; ready fires before the
336
+ // first rAF tick
337
+ app.once('frameend', () => this._bar?.complete());
278
338
 
279
339
  // Handle window resize to keep the canvas responsive
280
340
  window.addEventListener('resize', this._onWindowResize);
@@ -287,10 +347,13 @@ class AppElement extends AsyncElement {
287
347
  this._pickerDestroy();
288
348
 
289
349
  // Clean up the application
290
- if (this.app) {
291
- this.app.destroy();
350
+ if (this._app) {
351
+ this._app.destroy();
292
352
  this._app = null;
293
353
  }
354
+ this._loadProgress = 0;
355
+ this._bar?.destroy();
356
+ this._bar = null;
294
357
 
295
358
  // Remove event listeners
296
359
  window.removeEventListener('resize', this._onWindowResize);
@@ -312,10 +375,18 @@ class AppElement extends AsyncElement {
312
375
  const { width, height } = this.app!.graphicsDevice;
313
376
  this._picker = new Picker(this.app!, width, height);
314
377
 
315
- // Create bound handlers but don't attach them yet
316
- this._pointerHandlers.pointermove = this._onPointerMove.bind(this) as EventListener;
317
- this._pointerHandlers.pointerdown = this._onPointerDown.bind(this) as EventListener;
318
- this._pointerHandlers.pointerup = this._onPointerUp.bind(this) as EventListener;
378
+ // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
379
+ // each is wrapped to discard the promise - a listener must not return one, and nothing
380
+ // awaits the result.
381
+ const listener = (handler: (event: PointerEvent) => Promise<void>): EventListener => {
382
+ return (event: Event) => {
383
+ handler.call(this, event as PointerEvent);
384
+ };
385
+ };
386
+
387
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
388
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
389
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
319
390
 
320
391
  // Listen for pointer listeners being added/removed
321
392
  ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
@@ -369,31 +440,51 @@ class AppElement extends AsyncElement {
369
440
  return { x, y };
370
441
  }
371
442
 
372
- _onPointerMove(event: PointerEvent) {
373
- if (!this._picker || !this.app) return;
374
-
443
+ /**
444
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
445
+ *
446
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
447
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
448
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
449
+ * async variant works on both backends and does not block the main thread on a GPU read.
450
+ *
451
+ * @param event - The pointer event to pick under.
452
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
453
+ */
454
+ private async _pickNode(event: PointerEvent): Promise<GraphNode | null> {
375
455
  const camera = this.app!.root.findComponent('camera') as CameraComponent;
376
- if (!camera) return;
456
+ if (!camera) return null;
377
457
 
378
- // Use the helper to convert event coordinates into canvas/picker coordinates.
379
458
  const { x, y } = this._getPickerCoordinates(event);
380
459
 
381
- this._picker.prepare(camera, this.app!.scene);
382
- const selection = this._picker.getSelection(x, y);
460
+ this._picker!.prepare(camera, this.app!.scene);
461
+ const selection = await this._picker!.getSelectionAsync(x, y);
462
+ if (selection.length === 0) return null;
463
+
464
+ const item = selection[0];
465
+ return item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
466
+ }
467
+
468
+ async _onPointerMove(event: PointerEvent) {
469
+ if (!this._picker || !this.app) return;
470
+
471
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
472
+ // newest pick may update the hover state - an older one describes a pointer position the
473
+ // user has already left.
474
+ const token = ++this._pickToken;
475
+ const node = await this._pickNode(event);
476
+ if (token !== this._pickToken || !this._picker) return;
383
477
 
384
478
  // Get the currently hovered entity by walking up the hierarchy
385
479
  let newHoverEntity: EntityElement | null = null;
386
- if (selection.length > 0) {
387
- const item = selection[0];
388
- let currentNode: GraphNode | null = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
389
- while (currentNode !== null) {
390
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
391
- if (entityElement) {
392
- newHoverEntity = entityElement;
393
- break;
394
- }
395
- currentNode = currentNode.parent;
480
+ let currentNode = node;
481
+ while (currentNode !== null) {
482
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
483
+ if (entityElement) {
484
+ newHoverEntity = entityElement;
485
+ break;
396
486
  }
487
+ currentNode = currentNode.parent;
397
488
  }
398
489
 
399
490
  // Handle enter/leave events
@@ -415,51 +506,31 @@ class AppElement extends AsyncElement {
415
506
  }
416
507
  }
417
508
 
418
- _onPointerDown(event: PointerEvent) {
509
+ async _onPointerDown(event: PointerEvent) {
419
510
  if (!this._picker || !this.app) return;
420
511
 
421
- const camera = this.app!.root.findComponent('camera') as CameraComponent;
422
- if (!camera) return;
512
+ let currentNode = await this._pickNode(event);
513
+ if (!this._picker) return; // the element disconnected while the pick was in flight
423
514
 
424
- // Convert the event's pointer coordinates
425
- const { x, y } = this._getPickerCoordinates(event);
426
-
427
- this._picker.prepare(camera, this.app!.scene);
428
- const selection = this._picker.getSelection(x, y);
429
-
430
- if (selection.length > 0) {
431
- const item = selection[0];
432
- let currentNode: GraphNode | null = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
433
- while (currentNode !== null) {
434
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
435
- if (entityElement && entityElement.hasListeners('pointerdown')) {
436
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
437
- break;
438
- }
439
- currentNode = currentNode.parent;
515
+ while (currentNode !== null) {
516
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
517
+ if (entityElement && entityElement.hasListeners('pointerdown')) {
518
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
519
+ break;
440
520
  }
521
+ currentNode = currentNode.parent;
441
522
  }
442
523
  }
443
524
 
444
- _onPointerUp(event: PointerEvent) {
525
+ async _onPointerUp(event: PointerEvent) {
445
526
  if (!this._picker || !this.app) return;
446
527
 
447
- const camera = this.app!.root.findComponent('camera') as CameraComponent;
448
- if (!camera) return;
449
-
450
- // Convert CSS coordinates to picker coordinates
451
- const { x, y } = this._getPickerCoordinates(event);
528
+ const node = await this._pickNode(event);
529
+ if (!node || !this._picker) return;
452
530
 
453
- this._picker.prepare(camera, this.app!.scene);
454
- const selection = this._picker.getSelection(x, y);
455
-
456
- if (selection.length > 0) {
457
- const item = selection[0];
458
- const node = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
459
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
460
- if (entityElement && entityElement.hasListeners('pointerup')) {
461
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
462
- }
531
+ const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
532
+ if (entityElement && entityElement.hasListeners('pointerup')) {
533
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
463
534
  }
464
535
  }
465
536
 
@@ -528,7 +599,8 @@ class AppElement extends AsyncElement {
528
599
  }
529
600
 
530
601
  /**
531
- * Sets the graphics backend.
602
+ * Sets the graphics backend. Defaults to 'webgpu', which falls back to 'webgl2' if WebGPU
603
+ * is not supported by the browser.
532
604
  * @param value - The graphics backend ('webgpu', 'webgl2', or 'null').
533
605
  */
534
606
  set backend(value: 'webgpu' | 'webgl2' | 'null') {
@@ -588,6 +660,31 @@ class AppElement extends AsyncElement {
588
660
  return this._highResolution;
589
661
  }
590
662
 
663
+ /**
664
+ * Sets whether the application shows its built-in loading bar while it boots and preloads its
665
+ * assets. Enabled by default; setting `false` removes the bar immediately, while setting
666
+ * `true` has no effect until the element is next connected. The bar can be themed with the
667
+ * CSS custom properties `--pc-loading-bar-color`, `--pc-loading-bar-background` and
668
+ * `--pc-loading-bar-height`.
669
+ * @param value - The loading bar flag.
670
+ */
671
+ set loadingBar(value: boolean) {
672
+ this._loadingBar = value;
673
+ if (!value && this._bar) {
674
+ this._bar.destroy();
675
+ this._bar = null;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Gets whether the application shows its built-in loading bar while it boots and preloads
681
+ * its assets.
682
+ * @returns The loading bar flag.
683
+ */
684
+ get loadingBar() {
685
+ return this._loadingBar;
686
+ }
687
+
591
688
  /**
592
689
  * Sets the stencil flag.
593
690
  * @param value - The stencil flag.
@@ -605,10 +702,10 @@ class AppElement extends AsyncElement {
605
702
  }
606
703
 
607
704
  static get observedAttributes() {
608
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution'];
705
+ return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
609
706
  }
610
707
 
611
- attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
708
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
612
709
  switch (name) {
613
710
  case 'alpha':
614
711
  this.alpha = parseBool(newValue, true);
@@ -617,7 +714,7 @@ class AppElement extends AsyncElement {
617
714
  this.antialias = parseBool(newValue, true);
618
715
  break;
619
716
  case 'backend':
620
- this.backend = parseEnum(newValue, ['webgpu', 'webgl2', 'null'], 'webgl2', name);
717
+ this.backend = parseEnum(newValue, ['webgpu', 'webgl2', 'null'], 'webgpu', name);
621
718
  break;
622
719
  case 'depth':
623
720
  this.depth = parseBool(newValue, true);
@@ -625,6 +722,9 @@ class AppElement extends AsyncElement {
625
722
  case 'high-resolution':
626
723
  this.highResolution = parseBool(newValue, true);
627
724
  break;
725
+ case 'loading-bar':
726
+ this.loadingBar = parseBool(newValue, true);
727
+ break;
628
728
  case 'stencil':
629
729
  this.stencil = parseBool(newValue, true);
630
730
  break;
package/src/asset.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Asset, SPRITE_RENDERMODE_SIMPLE, SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED } from 'playcanvas';
2
2
 
3
3
  import { AsyncElement } from './async-element';
4
- import { parseBool, parseEnum, parseNumber } from './utils';
4
+ import { parseBool, parseEnum, parseNumber } from './parse';
5
5
  import { MeshoptDecoder } from '../lib/meshopt_decoder.module.js';
6
6
 
7
7
  const renderModes = new Map<'simple' | 'sliced' | 'tiled', number>([
@@ -16,11 +16,13 @@ const extToType = new Map([
16
16
  ['frag', 'shader'],
17
17
  ['glb', 'container'],
18
18
  ['glsl', 'shader'],
19
+ ['gltf', 'container'],
19
20
  ['hdr', 'texture'],
20
21
  ['html', 'html'],
21
22
  ['jpg', 'texture'],
22
23
  ['js', 'script'],
23
24
  ['json', 'json'],
25
+ ['ktx2', 'texture'],
24
26
  ['mp3', 'audio'],
25
27
  ['mjs', 'script'],
26
28
  ['ply', 'gsplat'],
@@ -82,6 +84,28 @@ const processBufferView = (
82
84
  * while the application is running are created and registered on insertion, and begin loading
83
85
  * immediately unless `lazy`. A `pc-asset` must be a direct child of `pc-app` — elements placed
84
86
  * elsewhere, or with an unsupported asset type, never become ready.
87
+ *
88
+ * Apart from `lazy`, these attributes are read once when the asset is created, so changing them
89
+ * later has no effect.
90
+ *
91
+ * @attribute {string} id - The identifier used to reference the asset from other elements.
92
+ * @attribute {string} src - The URL of the asset to load.
93
+ * @attribute {string} type - The asset type. Inferred from the `src` file extension when omitted.
94
+ * @attribute {string} data - Additional asset data, as a JSON object.
95
+ * @attribute {string} atlas - For a `sprite` asset, the `id` of the texture atlas asset it uses.
96
+ * The atlas must be declared before the sprite.
97
+ * @attribute {string} frame-keys - For a `sprite` asset, the atlas frame keys it uses, separated
98
+ * by spaces or commas.
99
+ * @attribute {number} pixels-per-unit - For a `sprite` asset, the number of pixels per world unit.
100
+ * @attribute {'simple' | 'sliced' | 'tiled'} render-mode - For a `sprite` asset, how the sprite is
101
+ * rendered when resized.
102
+ *
103
+ * @fires {Event} load - Fired each time the asset finishes loading, including a `lazy` asset
104
+ * loaded later and any subsequent reloads. Does not bubble — listen on this element, or use a
105
+ * capture-phase listener on an ancestor to observe every asset.
106
+ * @fires {ErrorEvent} error - Fired when the asset fails to load, with the engine's error in
107
+ * `message`. Does not bubble. The element still becomes ready — readiness means the load settled,
108
+ * not that it succeeded.
85
109
  */
86
110
  class AssetElement extends AsyncElement {
87
111
  private _lazy: boolean = false;
@@ -132,6 +156,16 @@ class AssetElement extends AsyncElement {
132
156
  this.destroyAsset();
133
157
  }
134
158
 
159
+ private _onAssetLoad() {
160
+ this.dispatchEvent(new Event('load'));
161
+ }
162
+
163
+ private _onAssetError(err: string | Error) {
164
+ this.dispatchEvent(new ErrorEvent('error', {
165
+ message: err instanceof Error ? err.message : String(err)
166
+ }));
167
+ }
168
+
135
169
  createAsset() {
136
170
  const id = this.getAttribute('id') || '';
137
171
  const src = this.getAttribute('src') || '';
@@ -169,6 +203,11 @@ class AssetElement extends AsyncElement {
169
203
  }
170
204
 
171
205
  this.asset.preload = !this._lazy;
206
+
207
+ // Forward the engine asset's load outcome as DOM events on this element, like <img>.
208
+ // Attached before the asset joins the registry, which is what starts a preloaded load.
209
+ this.asset.on('load', this._onAssetLoad, this);
210
+ this.asset.on('error', this._onAssetError, this);
172
211
  }
173
212
 
174
213
  /**
@@ -232,6 +271,9 @@ class AssetElement extends AsyncElement {
232
271
 
233
272
  destroyAsset() {
234
273
  if (this.asset) {
274
+ // A caller that keeps the Asset alive must not dispatch on a removed element
275
+ this.asset.off('load', this._onAssetLoad, this);
276
+ this.asset.off('error', this._onAssetError, this);
235
277
  // Deregister first so unload() can still notify the registry
236
278
  this.asset.registry?.remove(this.asset);
237
279
  this.asset.unload();
@@ -267,7 +309,7 @@ class AssetElement extends AsyncElement {
267
309
  return ['lazy'];
268
310
  }
269
311
 
270
- attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
312
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
271
313
  if (name === 'lazy') {
272
314
  this.lazy = parseBool(newValue, false);
273
315
  }
@@ -3,6 +3,9 @@ import { EntityElement } from './entity';
3
3
 
4
4
  /**
5
5
  * Base class for all PlayCanvas Web Components that initialize asynchronously.
6
+ *
7
+ * @fires {CustomEvent} ready - Fired once the element is fully initialized. Bubbles and is
8
+ * composed.
6
9
  */
7
10
  class AsyncElement extends HTMLElement {
8
11
  private _readyPromise: Promise<void>;
@@ -17,12 +20,22 @@ class AsyncElement extends HTMLElement {
17
20
  });
18
21
  }
19
22
 
20
- get closestApp(): AppElement {
21
- return this.parentElement?.closest('pc-app') as AppElement;
23
+ /**
24
+ * The nearest ancestor `<pc-app>` element, or `null` if this element has no `<pc-app>`
25
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
26
+ * @returns The closest app element, or `null`.
27
+ */
28
+ get closestApp(): AppElement | null {
29
+ return this.parentElement?.closest('pc-app') as AppElement | null ?? null;
22
30
  }
23
31
 
24
- get closestEntity(): EntityElement {
25
- return this.parentElement?.closest('pc-entity') as EntityElement;
32
+ /**
33
+ * The nearest ancestor `<pc-entity>` element, or `null` if this element has no `<pc-entity>`
34
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
35
+ * @returns The closest entity element, or `null`.
36
+ */
37
+ get closestEntity(): EntityElement | null {
38
+ return this.parentElement?.closest('pc-entity') as EntityElement | null ?? null;
26
39
  }
27
40
 
28
41
  /**