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