@playcanvas/web-components 0.10.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.
package/dist/pwc.cjs CHANGED
@@ -18,11 +18,21 @@ class AsyncElement extends HTMLElement {
18
18
  this._readyResolve = resolve;
19
19
  });
20
20
  }
21
+ /**
22
+ * The nearest ancestor `<pc-app>` element, or `null` if this element has no `<pc-app>`
23
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
24
+ * @returns The closest app element, or `null`.
25
+ */
21
26
  get closestApp() {
22
- return this.parentElement?.closest('pc-app');
27
+ return this.parentElement?.closest('pc-app') ?? null;
23
28
  }
29
+ /**
30
+ * The nearest ancestor `<pc-entity>` element, or `null` if this element has no `<pc-entity>`
31
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
32
+ * @returns The closest entity element, or `null`.
33
+ */
24
34
  get closestEntity() {
25
- return this.parentElement?.closest('pc-entity');
35
+ return this.parentElement?.closest('pc-entity') ?? null;
26
36
  }
27
37
  /**
28
38
  * Called when the element is fully initialized and ready. Subclasses should call this when
@@ -116,6 +126,116 @@ class ModuleElement extends HTMLElement {
116
126
  }
117
127
  customElements.define('pc-module', ModuleElement);
118
128
 
129
+ /** Covers the 0.2s opacity transition; jsdom never fires transitionend, so removal is timed. */
130
+ const REMOVAL_DELAY_MS = 250;
131
+ /**
132
+ * The slim progress bar `<pc-app>` shows while it boots and preloads. An implementation detail of
133
+ * AppElement rather than a custom element, so its shape can change without a breaking change.
134
+ *
135
+ * All styling is inline, so the library injects no stylesheet. The colors and height resolve CSS
136
+ * custom properties — `--pc-loading-bar-color`, `--pc-loading-bar-background` and
137
+ * `--pc-loading-bar-height` — so a page can theme the bar from `pc-app` or `:root`.
138
+ */
139
+ class LoadingBar {
140
+ _track;
141
+ _fill;
142
+ _sweep = null;
143
+ _removal = null;
144
+ /**
145
+ * Creates the bar and appends it to `parent`, starting in the indeterminate state.
146
+ * @param parent - The element to append the bar to.
147
+ */
148
+ constructor(parent) {
149
+ this._track = document.createElement('div');
150
+ this._track.setAttribute('role', 'progressbar');
151
+ this._track.setAttribute('aria-label', 'Loading');
152
+ this._track.setAttribute('aria-valuemin', '0');
153
+ this._track.setAttribute('aria-valuemax', '100');
154
+ // Fixed positioning matches the canvas, which always fills the window (FILLMODE_FILL_WINDOW)
155
+ this._track.style.cssText = [
156
+ 'position: fixed',
157
+ 'top: 0',
158
+ 'left: 0',
159
+ 'width: 100%',
160
+ 'height: var(--pc-loading-bar-height, 3px)',
161
+ 'background: var(--pc-loading-bar-background, rgba(0, 0, 0, 0.1))',
162
+ 'z-index: 10000',
163
+ 'pointer-events: none',
164
+ 'opacity: 1',
165
+ 'transition: opacity 0.2s ease'
166
+ ].join('; ');
167
+ this._fill = document.createElement('div');
168
+ this._fill.style.cssText = [
169
+ 'width: 100%',
170
+ 'height: 100%',
171
+ 'transform-origin: left center',
172
+ 'transform: scaleX(0)',
173
+ 'background: var(--pc-loading-bar-color, #f60)',
174
+ 'transition: transform 0.2s ease'
175
+ ].join('; ');
176
+ this._track.appendChild(this._fill);
177
+ parent.appendChild(this._track);
178
+ // Indeterminate sweep until the first progress() call reports a real total. No
179
+ // aria-valuenow is set, which is what marks a progressbar indeterminate. jsdom has no Web
180
+ // Animations API, so the guard degrades to a static bar there rather than crashing boot.
181
+ if (typeof this._fill.animate === 'function') {
182
+ this._sweep = this._fill.animate([
183
+ { transform: 'scaleX(0.25) translateX(-100%)' },
184
+ { transform: 'scaleX(0.25) translateX(500%)' }
185
+ ], {
186
+ duration: 1000,
187
+ iterations: Infinity,
188
+ easing: 'ease-in-out'
189
+ });
190
+ }
191
+ }
192
+ /**
193
+ * Reflects preload progress, switching the bar from indeterminate to determinate on the first
194
+ * call.
195
+ * @param loaded - The number of assets that have finished loading.
196
+ * @param total - The number of assets being preloaded.
197
+ */
198
+ progress(loaded, total) {
199
+ if (this._sweep) {
200
+ this._sweep.cancel();
201
+ this._sweep = null;
202
+ }
203
+ const fraction = total === 0 ? 1 : loaded / total;
204
+ this._track.setAttribute('aria-valuenow', String(Math.round(fraction * 100)));
205
+ this._fill.style.transform = `scaleX(${fraction})`;
206
+ }
207
+ /**
208
+ * Fills the bar, fades it out and removes it. Idempotent.
209
+ */
210
+ complete() {
211
+ if (this._removal !== null) {
212
+ return;
213
+ }
214
+ if (this._sweep) {
215
+ this._sweep.cancel();
216
+ this._sweep = null;
217
+ }
218
+ this._track.setAttribute('aria-valuenow', '100');
219
+ this._fill.style.transform = 'scaleX(1)';
220
+ this._track.style.opacity = '0';
221
+ this._removal = setTimeout(() => this._track.remove(), REMOVAL_DELAY_MS);
222
+ }
223
+ /**
224
+ * Removes the bar immediately, cancelling any pending fade. Idempotent.
225
+ */
226
+ destroy() {
227
+ if (this._sweep) {
228
+ this._sweep.cancel();
229
+ this._sweep = null;
230
+ }
231
+ if (this._removal !== null) {
232
+ clearTimeout(this._removal);
233
+ this._removal = null;
234
+ }
235
+ this._track.remove();
236
+ }
237
+ }
238
+
119
239
  const CSS_COLORS = {
120
240
  aliceblue: '#f0f8ff',
121
241
  antiquewhite: '#faebd7',
@@ -543,6 +663,11 @@ const getEntity = (ref) => {
543
663
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
544
664
  * The AppElement interface also inherits the properties and methods of the
545
665
  * {@link HTMLElement} interface.
666
+ *
667
+ * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
668
+ * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
669
+ * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
670
+ * not bubble.
546
671
  */
547
672
  class AppElement extends AsyncElement {
548
673
  /**
@@ -555,6 +680,8 @@ class AppElement extends AsyncElement {
555
680
  _depth = true;
556
681
  _stencil = true;
557
682
  _highResolution = true;
683
+ _loadingBar = true;
684
+ _bar = null;
558
685
  _hierarchyReady = false;
559
686
  _picker = null;
560
687
  _hasPointerListeners = {
@@ -565,20 +692,34 @@ class AppElement extends AsyncElement {
565
692
  pointermove: false
566
693
  };
567
694
  _hoveredEntity = null;
695
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
696
+ _pickToken = 0;
568
697
  _pointerHandlers = {
569
698
  pointermove: null,
570
699
  pointerdown: null,
571
700
  pointerup: null
572
701
  };
573
702
  _app = null;
703
+ _loadProgress = 0;
574
704
  /**
575
- * The PlayCanvas application instance. Available once the element is ready await
576
- * {@link whenReady} or the element's `ready()` promise before accessing it.
577
- * @returns The application instance.
705
+ * The PlayCanvas application instance. `null` until the element is ready, and again once it
706
+ * has been removed from the document — await {@link whenReady} or the element's `ready()`
707
+ * promise before accessing it.
708
+ * @returns The application instance, or `null`.
578
709
  */
579
710
  get app() {
580
711
  return this._app;
581
712
  }
713
+ /**
714
+ * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
715
+ * preloading begins (and again once the element has been removed from the document), and 1
716
+ * once preloading has finished — including when there was nothing to preload. Read this to
717
+ * initialize a loading UI; subsequent updates arrive via the `progress` event.
718
+ * @returns The preload progress.
719
+ */
720
+ get loadProgress() {
721
+ return this._loadProgress;
722
+ }
582
723
  /**
583
724
  * Creates a new AppElement instance.
584
725
  *
@@ -590,6 +731,11 @@ class AppElement extends AsyncElement {
590
731
  this._onWindowResize = this._onWindowResize.bind(this);
591
732
  }
592
733
  async connectedCallback() {
734
+ // Created before the first await, so the bar is visible while modules and the graphics
735
+ // device are created, and exists before any disconnect could need to clean it up
736
+ if (this._loadingBar && !this._bar) {
737
+ this._bar = new LoadingBar(this);
738
+ }
593
739
  // Get all pc-module elements that are direct children of the pc-app element
594
740
  const moduleElements = this.querySelectorAll(':scope > pc-module');
595
741
  // Wait for all modules to load
@@ -677,10 +823,11 @@ class AppElement extends AsyncElement {
677
823
  createOptions.lightmapper = playcanvas.Lightmapper;
678
824
  createOptions.batchManager = playcanvas.BatchManager;
679
825
  createOptions.xr = playcanvas.XrManager;
680
- this._app = new playcanvas.AppBase(this._canvas);
681
- this.app.init(createOptions);
682
- this.app.setCanvasFillMode(playcanvas.FILLMODE_FILL_WINDOW);
683
- this.app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
826
+ const app = new playcanvas.AppBase(this._canvas);
827
+ this._app = app;
828
+ app.init(createOptions);
829
+ app.setCanvasFillMode(playcanvas.FILLMODE_FILL_WINDOW);
830
+ app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
684
831
  this._pickerCreate();
685
832
  // Get all pc-asset elements that are direct children of the pc-app element
686
833
  const assetElements = this.querySelectorAll(':scope > pc-asset');
@@ -688,7 +835,7 @@ class AppElement extends AsyncElement {
688
835
  assetElement.createAsset();
689
836
  const asset = assetElement.asset;
690
837
  if (asset) {
691
- this.app.assets.add(asset);
838
+ app.assets.add(asset);
692
839
  }
693
840
  });
694
841
  // Get all pc-material elements that are direct children of the pc-app element
@@ -699,17 +846,39 @@ class AppElement extends AsyncElement {
699
846
  // Create all entities
700
847
  const entityElements = this.querySelectorAll('pc-entity');
701
848
  Array.from(entityElements).forEach((entityElement) => {
702
- entityElement.createEntity(this.app);
849
+ entityElement.createEntity(app);
703
850
  });
704
851
  // Build hierarchy
705
852
  entityElements.forEach((entityElement) => {
706
- entityElement.buildHierarchy(this.app);
853
+ entityElement.buildHierarchy(app);
707
854
  });
708
855
  this._hierarchyReady = true;
856
+ // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
857
+ // listener must be attached before preload() is called: an asset that is already loaded
858
+ // ticks synchronously inside it.
859
+ const total = app.assets.list({ preload: true }).length;
860
+ let loaded = 0;
861
+ const onPreloadProgress = () => {
862
+ loaded += 1;
863
+ this._loadProgress = loaded / total;
864
+ this._bar?.progress(loaded, total);
865
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
866
+ };
867
+ app.on('preload:progress', onPreloadProgress);
868
+ this._loadProgress = total === 0 ? 1 : 0;
869
+ this._bar?.progress(0, total);
870
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
709
871
  // Load assets before starting the application
710
- this.app.preload(() => {
872
+ app.preload(() => {
873
+ // Scope the counter to this preload pass, so a later app.preload() call by user code
874
+ // cannot push `loaded` past `total`
875
+ app.off('preload:progress', onPreloadProgress);
876
+ this._loadProgress = 1;
711
877
  // Start the application
712
- this.app.start();
878
+ app.start();
879
+ // Dismiss the bar only once a frame has actually rendered; ready fires before the
880
+ // first rAF tick
881
+ app.once('frameend', () => this._bar?.complete());
713
882
  // Handle window resize to keep the canvas responsive
714
883
  window.addEventListener('resize', this._onWindowResize);
715
884
  this._onReady();
@@ -718,10 +887,13 @@ class AppElement extends AsyncElement {
718
887
  disconnectedCallback() {
719
888
  this._pickerDestroy();
720
889
  // Clean up the application
721
- if (this.app) {
722
- this.app.destroy();
890
+ if (this._app) {
891
+ this._app.destroy();
723
892
  this._app = null;
724
893
  }
894
+ this._loadProgress = 0;
895
+ this._bar?.destroy();
896
+ this._bar = null;
725
897
  // Remove event listeners
726
898
  window.removeEventListener('resize', this._onWindowResize);
727
899
  // Remove the canvas
@@ -738,10 +910,17 @@ class AppElement extends AsyncElement {
738
910
  _pickerCreate() {
739
911
  const { width, height } = this.app.graphicsDevice;
740
912
  this._picker = new playcanvas.Picker(this.app, width, height);
741
- // Create bound handlers but don't attach them yet
742
- this._pointerHandlers.pointermove = this._onPointerMove.bind(this);
743
- this._pointerHandlers.pointerdown = this._onPointerDown.bind(this);
744
- this._pointerHandlers.pointerup = this._onPointerUp.bind(this);
913
+ // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
914
+ // each is wrapped to discard the promise - a listener must not return one, and nothing
915
+ // awaits the result.
916
+ const listener = (handler) => {
917
+ return (event) => {
918
+ handler.call(this, event);
919
+ };
920
+ };
921
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
922
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
923
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
745
924
  // Listen for pointer listeners being added/removed
746
925
  ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
747
926
  this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
@@ -789,29 +968,49 @@ class AppElement extends AsyncElement {
789
968
  const y = (event.clientY - canvasRect.top) * scaleY;
790
969
  return { x, y };
791
970
  }
792
- _onPointerMove(event) {
793
- if (!this._picker || !this.app)
794
- return;
971
+ /**
972
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
973
+ *
974
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
975
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
976
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
977
+ * async variant works on both backends and does not block the main thread on a GPU read.
978
+ *
979
+ * @param event - The pointer event to pick under.
980
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
981
+ */
982
+ async _pickNode(event) {
795
983
  const camera = this.app.root.findComponent('camera');
796
984
  if (!camera)
797
- return;
798
- // Use the helper to convert event coordinates into canvas/picker coordinates.
985
+ return null;
799
986
  const { x, y } = this._getPickerCoordinates(event);
800
987
  this._picker.prepare(camera, this.app.scene);
801
- const selection = this._picker.getSelection(x, y);
988
+ const selection = await this._picker.getSelectionAsync(x, y);
989
+ if (selection.length === 0)
990
+ return null;
991
+ const item = selection[0];
992
+ return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
993
+ }
994
+ async _onPointerMove(event) {
995
+ if (!this._picker || !this.app)
996
+ return;
997
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
998
+ // newest pick may update the hover state - an older one describes a pointer position the
999
+ // user has already left.
1000
+ const token = ++this._pickToken;
1001
+ const node = await this._pickNode(event);
1002
+ if (token !== this._pickToken || !this._picker)
1003
+ return;
802
1004
  // Get the currently hovered entity by walking up the hierarchy
803
1005
  let newHoverEntity = null;
804
- if (selection.length > 0) {
805
- const item = selection[0];
806
- let currentNode = item instanceof playcanvas.MeshInstance ? item.node : item.entity;
807
- while (currentNode !== null) {
808
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
809
- if (entityElement) {
810
- newHoverEntity = entityElement;
811
- break;
812
- }
813
- currentNode = currentNode.parent;
1006
+ let currentNode = node;
1007
+ while (currentNode !== null) {
1008
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
1009
+ if (entityElement) {
1010
+ newHoverEntity = entityElement;
1011
+ break;
814
1012
  }
1013
+ currentNode = currentNode.parent;
815
1014
  }
816
1015
  // Handle enter/leave events
817
1016
  if (this._hoveredEntity !== newHoverEntity) {
@@ -829,46 +1028,30 @@ class AppElement extends AsyncElement {
829
1028
  newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
830
1029
  }
831
1030
  }
832
- _onPointerDown(event) {
1031
+ async _onPointerDown(event) {
833
1032
  if (!this._picker || !this.app)
834
1033
  return;
835
- const camera = this.app.root.findComponent('camera');
836
- if (!camera)
837
- return;
838
- // Convert the event's pointer coordinates
839
- const { x, y } = this._getPickerCoordinates(event);
840
- this._picker.prepare(camera, this.app.scene);
841
- const selection = this._picker.getSelection(x, y);
842
- if (selection.length > 0) {
843
- const item = selection[0];
844
- let currentNode = item instanceof playcanvas.MeshInstance ? item.node : item.entity;
845
- while (currentNode !== null) {
846
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
847
- if (entityElement && entityElement.hasListeners('pointerdown')) {
848
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
849
- break;
850
- }
851
- currentNode = currentNode.parent;
1034
+ let currentNode = await this._pickNode(event);
1035
+ if (!this._picker)
1036
+ return; // the element disconnected while the pick was in flight
1037
+ while (currentNode !== null) {
1038
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
1039
+ if (entityElement && entityElement.hasListeners('pointerdown')) {
1040
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1041
+ break;
852
1042
  }
1043
+ currentNode = currentNode.parent;
853
1044
  }
854
1045
  }
855
- _onPointerUp(event) {
1046
+ async _onPointerUp(event) {
856
1047
  if (!this._picker || !this.app)
857
1048
  return;
858
- const camera = this.app.root.findComponent('camera');
859
- if (!camera)
1049
+ const node = await this._pickNode(event);
1050
+ if (!node || !this._picker)
860
1051
  return;
861
- // Convert CSS coordinates to picker coordinates
862
- const { x, y } = this._getPickerCoordinates(event);
863
- this._picker.prepare(camera, this.app.scene);
864
- const selection = this._picker.getSelection(x, y);
865
- if (selection.length > 0) {
866
- const item = selection[0];
867
- const node = item instanceof playcanvas.MeshInstance ? item.node : item.entity;
868
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`);
869
- if (entityElement && entityElement.hasListeners('pointerup')) {
870
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
871
- }
1052
+ const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`);
1053
+ if (entityElement && entityElement.hasListeners('pointerup')) {
1054
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
872
1055
  }
873
1056
  }
874
1057
  _onPointerListenerAdded(type) {
@@ -979,6 +1162,29 @@ class AppElement extends AsyncElement {
979
1162
  get highResolution() {
980
1163
  return this._highResolution;
981
1164
  }
1165
+ /**
1166
+ * Sets whether the application shows its built-in loading bar while it boots and preloads its
1167
+ * assets. Enabled by default; setting `false` removes the bar immediately, while setting
1168
+ * `true` has no effect until the element is next connected. The bar can be themed with the
1169
+ * CSS custom properties `--pc-loading-bar-color`, `--pc-loading-bar-background` and
1170
+ * `--pc-loading-bar-height`.
1171
+ * @param value - The loading bar flag.
1172
+ */
1173
+ set loadingBar(value) {
1174
+ this._loadingBar = value;
1175
+ if (!value && this._bar) {
1176
+ this._bar.destroy();
1177
+ this._bar = null;
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Gets whether the application shows its built-in loading bar while it boots and preloads
1182
+ * its assets.
1183
+ * @returns The loading bar flag.
1184
+ */
1185
+ get loadingBar() {
1186
+ return this._loadingBar;
1187
+ }
982
1188
  /**
983
1189
  * Sets the stencil flag.
984
1190
  * @param value - The stencil flag.
@@ -994,7 +1200,7 @@ class AppElement extends AsyncElement {
994
1200
  return this._stencil;
995
1201
  }
996
1202
  static get observedAttributes() {
997
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution'];
1203
+ return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
998
1204
  }
999
1205
  attributeChangedCallback(name, _oldValue, newValue) {
1000
1206
  switch (name) {
@@ -1013,6 +1219,9 @@ class AppElement extends AsyncElement {
1013
1219
  case 'high-resolution':
1014
1220
  this.highResolution = parseBool(newValue, true);
1015
1221
  break;
1222
+ case 'loading-bar':
1223
+ this.loadingBar = parseBool(newValue, true);
1224
+ break;
1016
1225
  case 'stencil':
1017
1226
  this.stencil = parseBool(newValue, true);
1018
1227
  break;
@@ -1084,9 +1293,10 @@ class EntityElement extends AsyncElement {
1084
1293
  _built = false;
1085
1294
  _entity = null;
1086
1295
  /**
1087
- * The PlayCanvas entity instance. Available once the element is ready await
1088
- * {@link whenReady} or the element's `ready()` promise before accessing it.
1089
- * @returns The entity instance.
1296
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
1297
+ * been removed from the document — await {@link whenReady} or the element's `ready()`
1298
+ * promise before accessing it.
1299
+ * @returns The entity instance, or `null`.
1090
1300
  */
1091
1301
  get entity() {
1092
1302
  return this._entity;
@@ -1098,16 +1308,18 @@ class EntityElement extends AsyncElement {
1098
1308
  if (this._entity) {
1099
1309
  return;
1100
1310
  }
1101
- // Create a new entity
1102
- const entity = new playcanvas.Entity(this.getAttribute('name') || this._name, app);
1311
+ // Seed from the cached fields rather than re-reading the attributes. Every observed
1312
+ // attribute is routed through its property setter by attributeChangedCallback, so the field
1313
+ // already holds the parsed attribute value - and it also holds anything assigned through the
1314
+ // property API before the app booted, which reading the attribute back would discard.
1315
+ const entity = new playcanvas.Entity(this._name, app);
1103
1316
  this._entity = entity;
1104
- entity.enabled = parseBool(this.getAttribute('enabled'), true);
1105
- entity.setLocalPosition(parseVec3(this.getAttribute('position'), playcanvas.Vec3.ZERO, 'position'));
1106
- entity.setLocalEulerAngles(parseVec3(this.getAttribute('rotation'), playcanvas.Vec3.ZERO, 'rotation'));
1107
- entity.setLocalScale(parseVec3(this.getAttribute('scale'), playcanvas.Vec3.ONE, 'scale'));
1108
- const tags = parseTags(this.getAttribute('tags'));
1109
- if (tags.length > 0) {
1110
- entity.tags.add(tags);
1317
+ entity.enabled = this._enabled;
1318
+ entity.setLocalPosition(this._position);
1319
+ entity.setLocalEulerAngles(this._rotation);
1320
+ entity.setLocalScale(this._scale);
1321
+ if (this._tags.length > 0) {
1322
+ entity.tags.add(this._tags);
1111
1323
  }
1112
1324
  }
1113
1325
  buildHierarchy(app) {
@@ -1126,8 +1338,15 @@ class EntityElement extends AsyncElement {
1126
1338
  connectedCallback() {
1127
1339
  // Wait for app to be ready
1128
1340
  const closestApp = this.closestApp;
1129
- if (!closestApp)
1341
+ if (!closestApp) {
1342
+ // An entity outside an application is inert and never becomes ready, so awaiting it
1343
+ // hangs. Warn rather than fail silently, naming the parent it requires, as every other
1344
+ // misplaced element does.
1345
+ const name = this.getAttribute('name');
1346
+ const label = name ? ` '${name}'` : '';
1347
+ console.warn(`pc-entity${label} must be a descendant of pc-app - entity not created`);
1130
1348
  return;
1349
+ }
1131
1350
  // If app is already running, create entity immediately
1132
1351
  if (closestApp.hierarchyReady) {
1133
1352
  const app = closestApp.app;
@@ -1145,10 +1364,15 @@ class EntityElement extends AsyncElement {
1145
1364
  }
1146
1365
  disconnectedCallback() {
1147
1366
  if (this.entity) {
1148
- // Notify all children that their entities are about to become invalid
1367
+ // Notify all children that their entities are about to become invalid. Both fields have
1368
+ // to be reset here, not just _entity: a descendant's own disconnectedCallback runs after
1369
+ // this one and skips its reset behind the `if (this.entity)` guard, because we have
1370
+ // already nulled the entity it tests. Leaving _built set would make buildHierarchy bail
1371
+ // on re-insertion, so the descendant would get a fresh entity that is never parented.
1149
1372
  const children = this.querySelectorAll('pc-entity');
1150
1373
  children.forEach((child) => {
1151
1374
  child._entity = null;
1375
+ child._built = false;
1152
1376
  });
1153
1377
  // Destroy the entity
1154
1378
  this.entity.destroy();
@@ -1603,6 +1827,13 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
1603
1827
  * @attribute {number} pixels-per-unit - For a `sprite` asset, the number of pixels per world unit.
1604
1828
  * @attribute {'simple' | 'sliced' | 'tiled'} render-mode - For a `sprite` asset, how the sprite is
1605
1829
  * rendered when resized.
1830
+ *
1831
+ * @fires {Event} load - Fired each time the asset finishes loading, including a `lazy` asset
1832
+ * loaded later and any subsequent reloads. Does not bubble — listen on this element, or use a
1833
+ * capture-phase listener on an ancestor to observe every asset.
1834
+ * @fires {ErrorEvent} error - Fired when the asset fails to load, with the engine's error in
1835
+ * `message`. Does not bubble. The element still becomes ready — readiness means the load settled,
1836
+ * not that it succeeded.
1606
1837
  */
1607
1838
  class AssetElement extends AsyncElement {
1608
1839
  _lazy = false;
@@ -1646,6 +1877,14 @@ class AssetElement extends AsyncElement {
1646
1877
  disconnectedCallback() {
1647
1878
  this.destroyAsset();
1648
1879
  }
1880
+ _onAssetLoad() {
1881
+ this.dispatchEvent(new Event('load'));
1882
+ }
1883
+ _onAssetError(err) {
1884
+ this.dispatchEvent(new ErrorEvent('error', {
1885
+ message: err instanceof Error ? err.message : String(err)
1886
+ }));
1887
+ }
1649
1888
  createAsset() {
1650
1889
  const id = this.getAttribute('id') || '';
1651
1890
  const src = this.getAttribute('src') || '';
@@ -1680,6 +1919,10 @@ class AssetElement extends AsyncElement {
1680
1919
  this.asset = new playcanvas.Asset(id, type, src ? { url: src } : null, data);
1681
1920
  }
1682
1921
  this.asset.preload = !this._lazy;
1922
+ // Forward the engine asset's load outcome as DOM events on this element, like <img>.
1923
+ // Attached before the asset joins the registry, which is what starts a preloaded load.
1924
+ this.asset.on('load', this._onAssetLoad, this);
1925
+ this.asset.on('error', this._onAssetError, this);
1683
1926
  }
1684
1927
  /**
1685
1928
  * Builds the `data` object for the asset from an optional inline `data` attribute (JSON) and,
@@ -1734,6 +1977,9 @@ class AssetElement extends AsyncElement {
1734
1977
  }
1735
1978
  destroyAsset() {
1736
1979
  if (this.asset) {
1980
+ // A caller that keeps the Asset alive must not dispatch on a removed element
1981
+ this.asset.off('load', this._onAssetLoad, this);
1982
+ this.asset.off('error', this._onAssetError, this);
1737
1983
  // Deregister first so unload() can still notify the registry
1738
1984
  this.asset.registry?.remove(this.asset);
1739
1985
  this.asset.unload();
@@ -1829,9 +2075,10 @@ class ComponentElement extends AsyncElement {
1829
2075
  this._appElement = null;
1830
2076
  }
1831
2077
  /**
1832
- * The PlayCanvas component instance. Available once the element is ready await
1833
- * {@link whenReady} or the element's `ready()` promise before accessing it.
1834
- * @returns The component instance.
2078
+ * The PlayCanvas component instance. `null` until the element is ready, and also for an
2079
+ * element that is not a descendant of a `<pc-entity>` — await {@link whenReady} or the
2080
+ * element's `ready()` promise before accessing it.
2081
+ * @returns The component instance, or `null`.
1835
2082
  */
1836
2083
  get component() {
1837
2084
  return this._component;
@@ -4724,8 +4971,14 @@ const roughnessAliases = ['roughness', 'roughness-map'];
4724
4971
  * created on insertion.
4725
4972
  *
4726
4973
  * The element is metal/rough by default: unlike a bare `StandardMaterial` it enables the metalness
4727
- * workflow, which is what the `metalness-*` attributes assume and what glTF means by PBR. The
4728
- * `roughness` and `roughness-map` attributes are aliases for `gloss` and `gloss-map` that
4974
+ * workflow, which is what the `metalness-*` attributes assume and what glTF means by PBR. It also
4975
+ * defaults `metalness` to 0 rather than the engine's 1, because those two defaults have to be
4976
+ * chosen together - the engine's 1 is unreachable under its own `useMetalness` of false, and with
4977
+ * the workflow on it would make every material fully metallic, so `<pc-material diffuse="crimson">`
4978
+ * would render as dark tinted reflections of an environment that may not exist rather than as a
4979
+ * crimson surface. `metalness="1"` remains one attribute away.
4980
+ *
4981
+ * The `roughness` and `roughness-map` attributes are aliases for `gloss` and `gloss-map` that
4729
4982
  * additionally invert the gloss channel; do not mix the two families on one element.
4730
4983
  *
4731
4984
  * The two aliases are documented here rather than on an accessor, because they resolve to the
@@ -4785,7 +5038,7 @@ class MaterialElement extends HTMLElement {
4785
5038
  _heightMapRotation = 0;
4786
5039
  _heightMapTiling = new playcanvas.Vec2(1, 1);
4787
5040
  _heightMapUv = 0;
4788
- _metalness = 1;
5041
+ _metalness = 0;
4789
5042
  _metalnessMap = '';
4790
5043
  _metalnessMapChannel = 'g';
4791
5044
  _metalnessMapOffset = new playcanvas.Vec2(0, 0);
@@ -6728,7 +6981,7 @@ class MaterialElement extends HTMLElement {
6728
6981
  this.heightMapUv = parseNumber(newValue, 0, name);
6729
6982
  break;
6730
6983
  case 'metalness':
6731
- this.metalness = parseNumber(newValue, 1, name);
6984
+ this.metalness = parseNumber(newValue, 0, name);
6732
6985
  break;
6733
6986
  case 'metalness-map':
6734
6987
  this.metalnessMap = newValue ?? '';
@@ -9326,30 +9579,58 @@ class SceneElement extends AsyncElement {
9326
9579
  _gravity = new playcanvas.Vec3(0, -9.81, 0);
9327
9580
  _scene = null;
9328
9581
  /**
9329
- * The PlayCanvas scene instance. Available once the element is ready — await
9582
+ * The PlayCanvas scene instance. `null` until the element is ready — await
9330
9583
  * {@link whenReady} or the element's `ready()` promise before accessing it.
9331
- * @returns The scene instance.
9584
+ * @returns The scene instance, or `null`.
9332
9585
  */
9333
9586
  get scene() {
9334
9587
  return this._scene;
9335
9588
  }
9336
9589
  async connectedCallback() {
9337
- await this.closestApp?.ready();
9338
- this._scene = this.closestApp.app.scene;
9590
+ const appElement = this.closestApp;
9591
+ if (!appElement) {
9592
+ console.warn('pc-scene must be a descendant of pc-app - scene settings not applied');
9593
+ return;
9594
+ }
9595
+ await appElement.ready();
9596
+ // The element may have been removed or re-parented while waiting for the app. Matches the
9597
+ // guard in AssetElement and MaterialElement, but compares closestApp rather than
9598
+ // parentElement because pc-scene resolves its app by ancestor rather than direct child.
9599
+ // Without this, a scene re-parented mid-await would take its Scene from the app it started
9600
+ // under while _applyGravity resolved the app it ended up under, splitting the two.
9601
+ if (!this.isConnected || this.closestApp !== appElement) {
9602
+ return;
9603
+ }
9604
+ // The application is gone if the tree was torn down while we awaited readiness. There is
9605
+ // nothing to configure and nothing the author can act on, so this stays silent.
9606
+ const app = appElement.app;
9607
+ if (!app) {
9608
+ return;
9609
+ }
9610
+ this._scene = app.scene;
9339
9611
  this.updateSceneSettings();
9340
9612
  this._onReady();
9341
9613
  }
9342
9614
  updateSceneSettings() {
9343
- if (this.scene) {
9344
- this.scene.fog.type = this._fog;
9345
- this.scene.fog.color = this._fogColor;
9346
- this.scene.fog.density = this._fogDensity;
9347
- this.scene.fog.start = this._fogStart;
9348
- this.scene.fog.end = this._fogEnd;
9349
- const appElement = this.parentElement;
9350
- appElement.app.systems.rigidbody.gravity.copy(this._gravity);
9615
+ if (this._scene) {
9616
+ this._scene.fog.type = this._fog;
9617
+ this._scene.fog.color = this._fogColor;
9618
+ this._scene.fog.density = this._fogDensity;
9619
+ this._scene.fog.start = this._fogStart;
9620
+ this._scene.fog.end = this._fogEnd;
9621
+ this._applyGravity(this._gravity);
9351
9622
  }
9352
9623
  }
9624
+ /**
9625
+ * Applies gravity to the rigid body system. Resolved through `closestApp` rather than
9626
+ * `parentElement` so that a `<pc-scene>` nested inside a wrapper element behaves the same as
9627
+ * a direct child, matching how `connectedCallback` resolves the application.
9628
+ *
9629
+ * @param value - The gravity to apply.
9630
+ */
9631
+ _applyGravity(value) {
9632
+ this.closestApp?.app?.systems.rigidbody?.gravity.copy(value);
9633
+ }
9353
9634
  /**
9354
9635
  * Sets the fog type of the scene. Can be `none`, `linear`, `exp` or `exp2`. Defaults to
9355
9636
  * `none`.
@@ -9442,9 +9723,8 @@ class SceneElement extends AsyncElement {
9442
9723
  */
9443
9724
  set gravity(value) {
9444
9725
  this._gravity = value;
9445
- if (this.scene) {
9446
- const appElement = this.parentElement;
9447
- appElement.app.systems.rigidbody.gravity.copy(value);
9726
+ if (this._scene) {
9727
+ this._applyGravity(value);
9448
9728
  }
9449
9729
  }
9450
9730
  /**