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