@playcanvas/web-components 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +31 -49
  3. package/dist/asset.d.ts +152 -12
  4. package/dist/async-element.d.ts +26 -9
  5. package/dist/components/button-component.d.ts +3 -7
  6. package/dist/components/camera-component.d.ts +3 -7
  7. package/dist/components/collision-component.d.ts +19 -7
  8. package/dist/components/component.d.ts +41 -4
  9. package/dist/components/element-component.d.ts +4 -8
  10. package/dist/components/gsplat-component.d.ts +2 -7
  11. package/dist/components/layoutchild-component.d.ts +2 -7
  12. package/dist/components/layoutgroup-component.d.ts +3 -7
  13. package/dist/components/light-component.d.ts +3 -7
  14. package/dist/components/listener-component.d.ts +1 -6
  15. package/dist/components/particlesystem-component.d.ts +2 -7
  16. package/dist/components/render-component.d.ts +2 -7
  17. package/dist/components/rigidbody-component.d.ts +3 -7
  18. package/dist/components/screen-component.d.ts +3 -7
  19. package/dist/components/script-component.d.ts +8 -20
  20. package/dist/components/script.d.ts +2 -22
  21. package/dist/components/scrollbar-component.d.ts +2 -7
  22. package/dist/components/scrollview-component.d.ts +3 -7
  23. package/dist/components/sound-component.d.ts +2 -7
  24. package/dist/components/sound-slot.d.ts +8 -6
  25. package/dist/custom-elements.json +5824 -10581
  26. package/dist/entity-base.d.ts +67 -0
  27. package/dist/entity.d.ts +7 -48
  28. package/dist/index.d.ts +41 -1
  29. package/dist/material.d.ts +14 -13
  30. package/dist/model.d.ts +43 -5
  31. package/dist/module.d.ts +0 -6
  32. package/dist/node.d.ts +253 -0
  33. package/dist/parse.d.ts +2 -1
  34. package/dist/pwc.cjs +1830 -274
  35. package/dist/pwc.cjs.map +1 -1
  36. package/dist/pwc.js +1830 -274
  37. package/dist/pwc.js.map +1 -1
  38. package/dist/pwc.min.js +1 -1
  39. package/dist/pwc.min.js.map +1 -1
  40. package/dist/pwc.min.mjs +1 -1
  41. package/dist/pwc.min.mjs.map +1 -1
  42. package/dist/pwc.mjs +1830 -276
  43. package/dist/pwc.mjs.map +1 -1
  44. package/dist/scene.d.ts +4 -7
  45. package/dist/sky.d.ts +13 -5
  46. package/dist/vscode.html-custom-data.json +148 -26
  47. package/dist/web-types.json +894 -581
  48. package/package.json +9 -8
  49. package/src/app.ts +163 -88
  50. package/src/asset.ts +472 -36
  51. package/src/async-element.ts +39 -12
  52. package/src/components/button-component.ts +5 -9
  53. package/src/components/camera-component.ts +24 -10
  54. package/src/components/collision-component.ts +61 -15
  55. package/src/components/component.ts +151 -11
  56. package/src/components/element-component.ts +26 -30
  57. package/src/components/gsplat-component.ts +4 -9
  58. package/src/components/layoutchild-component.ts +4 -9
  59. package/src/components/layoutgroup-component.ts +14 -9
  60. package/src/components/light-component.ts +42 -12
  61. package/src/components/listener-component.ts +1 -7
  62. package/src/components/particlesystem-component.ts +7 -15
  63. package/src/components/render-component.ts +5 -10
  64. package/src/components/rigidbody-component.ts +23 -16
  65. package/src/components/screen-component.ts +5 -9
  66. package/src/components/script-component.ts +108 -46
  67. package/src/components/script.ts +38 -33
  68. package/src/components/scrollbar-component.ts +6 -16
  69. package/src/components/scrollview-component.ts +16 -11
  70. package/src/components/sound-component.ts +10 -15
  71. package/src/components/sound-slot.ts +30 -20
  72. package/src/entity-base.ts +136 -0
  73. package/src/entity.ts +47 -118
  74. package/src/index.ts +50 -1
  75. package/src/loading-bar.ts +8 -8
  76. package/src/material.ts +65 -39
  77. package/src/model.ts +140 -17
  78. package/src/module.ts +8 -7
  79. package/src/node.ts +715 -0
  80. package/src/parse.ts +62 -17
  81. package/src/scene.ts +12 -9
  82. package/src/sky.ts +50 -10
package/dist/pwc.cjs CHANGED
@@ -5,12 +5,14 @@ var playcanvas = require('playcanvas');
5
5
  /**
6
6
  * Base class for all PlayCanvas Web Components that initialize asynchronously.
7
7
  *
8
- * @fires {CustomEvent} ready - Fired once the element is fully initialized. Bubbles and is
9
- * composed.
8
+ * @fires {CustomEvent} ready - Fired when the element is fully initialized once per readiness
9
+ * cycle, so an element that is torn down and re-initialized (for example by removing and
10
+ * re-inserting it) fires it again. Bubbles and is composed.
10
11
  */
11
12
  class AsyncElement extends HTMLElement {
12
13
  _readyPromise;
13
14
  _readyResolve;
15
+ _readyResolved = false;
14
16
  /** @ignore */
15
17
  constructor() {
16
18
  super();
@@ -27,25 +29,50 @@ class AsyncElement extends HTMLElement {
27
29
  return this.parentElement?.closest('pc-app') ?? null;
28
30
  }
29
31
  /**
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`.
32
+ * The nearest ancestor element that fronts an entity `<pc-entity>` or `<pc-node>` — or
33
+ * `null` if this element has no such ancestor. The search starts at the parent, so an element
34
+ * never resolves to itself.
35
+ * @returns The closest entity-fronting element, or `null`.
33
36
  */
34
37
  get closestEntity() {
35
- return this.parentElement?.closest('pc-entity') ?? null;
38
+ return this.parentElement?.closest('pc-entity, pc-node') ?? null;
36
39
  }
37
40
  /**
38
41
  * Called when the element is fully initialized and ready. Subclasses should call this when
39
42
  * they're ready. Resolves the ready promise and dispatches a bubbling, composed `ready`
40
- * event.
43
+ * event. Signals at most once per readiness cycle: a repeat call before {@link _resetReady}
44
+ * has re-armed the promise does nothing.
41
45
  */
42
46
  _onReady() {
47
+ if (this._readyResolved)
48
+ return;
49
+ this._readyResolved = true;
43
50
  this._readyResolve();
44
51
  this.dispatchEvent(new CustomEvent('ready', { bubbles: true, composed: true }));
45
52
  }
53
+ /**
54
+ * Returns the ready promise to its pending state. Subclasses should call this when the
55
+ * resource their readiness announced is torn down (typically from `disconnectedCallback`),
56
+ * so that a later re-initialization can signal readiness again. Does nothing while the
57
+ * promise is still pending — an in-flight waiter carries over to the next readiness cycle
58
+ * rather than being stranded on a promise nothing will ever resolve.
59
+ */
60
+ _resetReady() {
61
+ if (!this._readyResolved)
62
+ return;
63
+ this._readyResolved = false;
64
+ this._readyPromise = new Promise((resolve) => {
65
+ this._readyResolve = resolve;
66
+ });
67
+ }
46
68
  /**
47
69
  * Returns a promise that resolves with this element when it's ready. This is the low-level
48
70
  * primitive underlying {@link whenReady}, which is the recommended way to wait for elements.
71
+ *
72
+ * Readiness tracks the element's current lifecycle: once a ready element is torn down (for
73
+ * example by removing it from the document), this returns a fresh promise that resolves when
74
+ * the element is next ready. A promise obtained earlier stays resolved — call this again
75
+ * after re-inserting an element rather than reusing a promise from before its removal.
49
76
  * @returns A promise that resolves with this element when it's ready.
50
77
  */
51
78
  ready() {
@@ -120,7 +147,14 @@ class ModuleElement extends HTMLElement {
120
147
  });
121
148
  }
122
149
  }
123
- getLoadPromise() {
150
+ /**
151
+ * Returns the promise that settles when the module has loaded. Awaited by the containing
152
+ * `<pc-app>` element before it creates its graphics device.
153
+ *
154
+ * @returns The load promise.
155
+ * @internal
156
+ */
157
+ _getLoadPromise() {
124
158
  return this.loadPromise;
125
159
  }
126
160
  }
@@ -179,10 +213,7 @@ class LoadingBar {
179
213
  // aria-valuenow is set, which is what marks a progressbar indeterminate. jsdom has no Web
180
214
  // Animations API, so the guard degrades to a static bar there rather than crashing boot.
181
215
  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
- ], {
216
+ this._sweep = this._fill.animate([{ transform: 'scaleX(0.25) translateX(-100%)' }, { transform: 'scaleX(0.25) translateX(500%)' }], {
186
217
  duration: 1000,
187
218
  iterations: Infinity,
188
219
  easing: 'ease-in-out'
@@ -415,7 +446,7 @@ const CSS_COLORS = {
415
446
  */
416
447
  const parseComponents = (value, count) => {
417
448
  const components = value.trim().split(/\s+/).map(Number);
418
- if (components.length !== count || components.some(component => !Number.isFinite(component))) {
449
+ if (components.length !== count || components.some((component) => !Number.isFinite(component))) {
419
450
  return null;
420
451
  }
421
452
  return components;
@@ -471,7 +502,10 @@ const parseColor = (value, defaultValue, attribute) => {
471
502
  if (/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) {
472
503
  let hex = value.slice(1);
473
504
  if (hex.length === 3 || hex.length === 4) {
474
- hex = hex.split('').map(char => char + char).join('');
505
+ hex = hex
506
+ .split('')
507
+ .map((char) => char + char)
508
+ .join('');
475
509
  }
476
510
  return new playcanvas.Color().fromString(`#${hex}`);
477
511
  }
@@ -568,7 +602,10 @@ const parseTags = (value, defaultValue = []) => {
568
602
  // caller's default, or a later mutation would write back through it.
569
603
  return [...defaultValue];
570
604
  }
571
- return value.split(',').map(tag => tag.trim()).filter(tag => tag !== '');
605
+ return value
606
+ .split(',')
607
+ .map((tag) => tag.trim())
608
+ .filter((tag) => tag !== '');
572
609
  };
573
610
  /**
574
611
  * Parse a Vec2 attribute value. The expected format is 2 space-separated numbers (e.g. '1 2').
@@ -660,6 +697,8 @@ const getEntity = (ref) => {
660
697
  return element?.entity ?? null;
661
698
  };
662
699
 
700
+ /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
701
+ const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'];
663
702
  /**
664
703
  * The AppElement interface provides properties and methods for manipulating
665
704
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
@@ -690,11 +729,25 @@ class AppElement extends AsyncElement {
690
729
  */
691
730
  _optionsLocked = false;
692
731
  _bar = null;
732
+ /**
733
+ * Whether the application has created its initial entity hierarchy. Read by EntityElement to
734
+ * decide whether a newly connected element must create its entity itself or leave it to the
735
+ * boot sweep.
736
+ * @internal
737
+ */
693
738
  _hierarchyReady = false;
739
+ /**
740
+ * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
741
+ * itself wherever it resumes from an await if the value has moved on — so a boot whose
742
+ * element was removed cannot complete against a torn-down element, and a boot whose element
743
+ * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
744
+ */
745
+ _bootGeneration = 0;
694
746
  /**
695
747
  * The elements backing this application's entities, keyed by the entity itself. Registered
696
- * by EntityElement at creation and removed when an entity is destroyed, this joins engine
697
- * scene nodes back to their owning elements by identity - never by name.
748
+ * by EntityElement at creation (and NodeElement at binding) and removed when an entity is
749
+ * destroyed or unbound, this joins engine scene nodes back to their owning elements by
750
+ * identity - never by name.
698
751
  */
699
752
  _entityElements = new Map();
700
753
  _picker = null;
@@ -743,8 +796,16 @@ class AppElement extends AsyncElement {
743
796
  super();
744
797
  // Bind methods to maintain 'this' context
745
798
  this._onWindowResize = this._onWindowResize.bind(this);
799
+ // Track pointer listeners being added to and removed from descendant entities.
800
+ // Registered once here rather than on every boot - the handlers no-op while there is no
801
+ // canvas, and a re-booted element must not stack a second set.
802
+ pointerEventTypes.forEach((type) => {
803
+ this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
804
+ this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
805
+ });
746
806
  }
747
807
  async connectedCallback() {
808
+ const generation = ++this._bootGeneration;
748
809
  // Created before the first await, so the bar is visible while modules and the graphics
749
810
  // device are created, and exists before any disconnect could need to clean it up
750
811
  if (this._loadingBar && !this._bar) {
@@ -753,7 +814,12 @@ class AppElement extends AsyncElement {
753
814
  // Get all pc-module elements that are direct children of the pc-app element
754
815
  const moduleElements = this.querySelectorAll(':scope > pc-module');
755
816
  // Wait for all modules to load
756
- await Promise.all(Array.from(moduleElements).map(module => module.getLoadPromise()));
817
+ await Promise.all(Array.from(moduleElements).map((module) => module._getLoadPromise()));
818
+ // The element may have been removed while the modules loaded. Nothing beyond the loading
819
+ // bar exists yet, and disconnectedCallback has already destroyed that.
820
+ if (generation !== this._bootGeneration) {
821
+ return;
822
+ }
757
823
  // Create and append the canvas to the element
758
824
  this._canvas = document.createElement('canvas');
759
825
  this.appendChild(this._canvas);
@@ -773,6 +839,13 @@ class AppElement extends AsyncElement {
773
839
  deviceTypes: deviceTypes,
774
840
  stencil: this._stencilBuffer
775
841
  });
842
+ // The element may have been removed while the device was created. disconnectedCallback
843
+ // has already cleaned up the canvas; the device was created inside the await, so it is
844
+ // this boot's to release.
845
+ if (generation !== this._bootGeneration) {
846
+ device.destroy();
847
+ return;
848
+ }
776
849
  // Assigned rather than resolved to a number here: the engine caps against the live
777
850
  // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
778
851
  // display when a window moves between monitors of differing density.
@@ -849,27 +922,41 @@ class AppElement extends AsyncElement {
849
922
  this._pickerCreate();
850
923
  // Get all pc-asset elements that are direct children of the pc-app element
851
924
  const assetElements = this.querySelectorAll(':scope > pc-asset');
852
- Array.from(assetElements).forEach((assetElement) => {
853
- assetElement.createAsset();
925
+ for (const assetElement of Array.from(assetElements)) {
926
+ assetElement._createAsset();
854
927
  const asset = assetElement.asset;
855
928
  if (asset) {
856
929
  app.assets.add(asset);
930
+ // Adding a fileless asset (one built purely from data, such as a sprite)
931
+ // completes it synchronously, dispatching the element's load event - whose
932
+ // listeners may have removed this element. Stop before the next addition
933
+ // reaches the destroyed registry, and before orphan entities are created.
934
+ if (generation !== this._bootGeneration) {
935
+ return;
936
+ }
857
937
  }
858
- });
938
+ }
859
939
  // Get all pc-material elements that are direct children of the pc-app element
860
940
  const materialElements = this.querySelectorAll(':scope > pc-material');
861
941
  Array.from(materialElements).forEach((materialElement) => {
862
- materialElement.createMaterial();
942
+ materialElement._createMaterial();
863
943
  });
864
944
  // Create all entities
865
945
  const entityElements = this.querySelectorAll('pc-entity');
866
946
  Array.from(entityElements).forEach((entityElement) => {
867
- entityElement.createEntity(app);
947
+ entityElement._createEntity(app);
868
948
  });
869
949
  // Build hierarchy
870
950
  entityElements.forEach((entityElement) => {
871
- entityElement.buildHierarchy(app);
951
+ entityElement._buildHierarchy(app);
872
952
  });
953
+ // Building the hierarchy dispatched each entity's ready event synchronously, and a
954
+ // listener may have removed the element. The sweep itself degrades safely - destroying
955
+ // the application nulls every element's entity, so the remaining builds no-op - but the
956
+ // teardown's reset must not be overwritten here.
957
+ if (generation !== this._bootGeneration) {
958
+ return;
959
+ }
873
960
  this._hierarchyReady = true;
874
961
  // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
875
962
  // listener must be attached before preload() is called: an asset that is already loaded
@@ -886,8 +973,19 @@ class AppElement extends AsyncElement {
886
973
  this._loadProgress = total === 0 ? 1 : 0;
887
974
  this._bar?.progress(0, total);
888
975
  this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
976
+ // The progress dispatch above ran listeners synchronously, and one may have removed the
977
+ // element. The application is already destroyed - it must not be asked to preload.
978
+ if (generation !== this._bootGeneration) {
979
+ return;
980
+ }
889
981
  // Load assets before starting the application
890
982
  app.preload(() => {
983
+ // The element may have been removed while assets loaded. The application is already
984
+ // destroyed, so it must not be started — and readiness must not be signaled for a
985
+ // boot that no longer owns the element.
986
+ if (generation !== this._bootGeneration) {
987
+ return;
988
+ }
891
989
  // Scope the counter to this preload pass, so a later app.preload() call by user code
892
990
  // cannot push `loaded` past `total`
893
991
  app.off('preload:progress', onPreloadProgress);
@@ -903,6 +1001,9 @@ class AppElement extends AsyncElement {
903
1001
  });
904
1002
  }
905
1003
  disconnectedCallback() {
1004
+ // Invalidate any boot still in flight, so it abandons itself when it next resumes
1005
+ // instead of completing against a torn-down element.
1006
+ this._bootGeneration++;
906
1007
  this._optionsLocked = false;
907
1008
  this._pickerDestroy();
908
1009
  // Clean up the application. Destroying it destroys every entity, whose destroy hooks
@@ -915,6 +1016,11 @@ class AppElement extends AsyncElement {
915
1016
  this._loadProgress = 0;
916
1017
  this._bar?.destroy();
917
1018
  this._bar = null;
1019
+ // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1020
+ // must neither see a hierarchy that no longer exists nor resume against a readiness that
1021
+ // no longer holds.
1022
+ this._hierarchyReady = false;
1023
+ this._resetReady();
918
1024
  // Remove event listeners
919
1025
  window.removeEventListener('resize', this._onWindowResize);
920
1026
  // Remove the canvas
@@ -942,14 +1048,11 @@ class AppElement extends AsyncElement {
942
1048
  this._pointerHandlers.pointermove = listener(this._onPointerMove);
943
1049
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
944
1050
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
945
- // Listen for pointer listeners being added/removed
946
- ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
947
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
948
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
949
- // Attach canvas handlers for listeners registered before this point (e.g. handlers
950
- // created from onpointer* attributes when their elements were first upgraded)
951
- const anyListeners = Array.from(this.querySelectorAll('pc-entity'))
952
- .some(entity => entity.hasListeners(type));
1051
+ // Attach canvas handlers for listeners registered before this boot (e.g. handlers
1052
+ // created from onpointer* attributes when their elements were first upgraded, or
1053
+ // listeners carried over from before a re-boot)
1054
+ pointerEventTypes.forEach((type) => {
1055
+ const anyListeners = Array.from(this.querySelectorAll('pc-entity, pc-node')).some((entity) => entity._hasListeners(type));
953
1056
  if (anyListeners) {
954
1057
  this._onPointerListenerAdded(type);
955
1058
  }
@@ -964,6 +1067,7 @@ class AppElement extends AsyncElement {
964
1067
  });
965
1068
  }
966
1069
  this._picker = null;
1070
+ this._hoveredEntity = null;
967
1071
  this._pointerHandlers = {
968
1072
  pointermove: null,
969
1073
  pointerdown: null,
@@ -978,12 +1082,12 @@ class AppElement extends AsyncElement {
978
1082
  };
979
1083
  }
980
1084
  /**
981
- * Registers the element that created an entity. Called by EntityElement when it creates its
982
- * entity.
1085
+ * Registers the element that fronts an entity. Called by EntityElement when it creates its
1086
+ * entity, and by NodeElement when it binds one.
983
1087
  *
984
1088
  * @param entity - The entity.
985
- * @param element - The element that created it.
986
- * @ignore
1089
+ * @param element - The element that fronts it.
1090
+ * @internal
987
1091
  */
988
1092
  _registerEntityElement(entity, element) {
989
1093
  this._entityElements.set(entity, element);
@@ -992,27 +1096,28 @@ class AppElement extends AsyncElement {
992
1096
  * Removes the registration for a destroyed entity. Called by EntityElement.
993
1097
  *
994
1098
  * @param entity - The entity.
995
- * @ignore
1099
+ * @internal
996
1100
  */
997
1101
  _unregisterEntityElement(entity) {
998
1102
  this._entityElements.delete(entity);
999
1103
  }
1000
1104
  /**
1001
- * Returns the `<pc-entity>` element whose backing entity is `entity`, or `null` if the
1002
- * entity was not created by an element of this application - for example, a node inside a
1003
- * model's instantiated hierarchy, or an entity created through the engine API.
1105
+ * Returns the `<pc-entity>` or `<pc-node>` element whose backing entity is `entity`, or
1106
+ * `null` if the entity is not fronted by an element of this application - for example, an
1107
+ * unbound node inside a model's instantiated hierarchy, or an entity created through the
1108
+ * engine API.
1004
1109
  *
1005
1110
  * @param entity - The entity to look up.
1006
- * @returns The element backing the entity, or `null`.
1111
+ * @returns The element fronting the entity, or `null`.
1007
1112
  */
1008
1113
  elementFromEntity(entity) {
1009
1114
  return this._entityElements.get(entity) ?? null;
1010
1115
  }
1011
1116
  /**
1012
1117
  * Resolves the element that owns a picked node: the nearest node up the parent chain -
1013
- * starting with the node itself - that was created by a `<pc-entity>` of this application.
1014
- * A hit inside a model's instantiated hierarchy therefore resolves to the element hosting
1015
- * the model.
1118
+ * starting with the node itself - that is fronted by a `<pc-entity>` or `<pc-node>` of this
1119
+ * application. A hit inside a model's instantiated hierarchy therefore resolves to the
1120
+ * nearest bound `<pc-node>`, or failing that the element hosting the model.
1016
1121
  *
1017
1122
  * @param node - The picked node, or `null`.
1018
1123
  * @returns The owning element, or `null`.
@@ -1038,7 +1143,7 @@ class AppElement extends AsyncElement {
1038
1143
  _elementWithListener(node, type) {
1039
1144
  while (node !== null) {
1040
1145
  const element = this._entityElements.get(node);
1041
- if (element?.hasListeners(type)) {
1146
+ if (element?._hasListeners(type)) {
1042
1147
  return element;
1043
1148
  }
1044
1149
  node = node.parent;
@@ -1095,17 +1200,17 @@ class AppElement extends AsyncElement {
1095
1200
  const newHoverEntity = this._elementFromNode(node);
1096
1201
  // Handle enter/leave events
1097
1202
  if (this._hoveredEntity !== newHoverEntity) {
1098
- if (this._hoveredEntity && this._hoveredEntity.hasListeners('pointerleave')) {
1203
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1099
1204
  this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1100
1205
  }
1101
- if (newHoverEntity && newHoverEntity.hasListeners('pointerenter')) {
1206
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1102
1207
  newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1103
1208
  }
1104
1209
  }
1105
1210
  // Update hover state
1106
1211
  this._hoveredEntity = newHoverEntity;
1107
1212
  // Handle pointermove event
1108
- if (newHoverEntity && newHoverEntity.hasListeners('pointermove')) {
1213
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1109
1214
  newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1110
1215
  }
1111
1216
  }
@@ -1135,22 +1240,21 @@ class AppElement extends AsyncElement {
1135
1240
  if (!this._hasPointerListeners[type] && this._canvas) {
1136
1241
  this._hasPointerListeners[type] = true;
1137
1242
  // For enter/leave events, we need the move handler
1138
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
1139
- this._pointerHandlers.pointermove :
1140
- this._pointerHandlers[type];
1243
+ const handler = type === 'pointerenter' || type === 'pointerleave'
1244
+ ? this._pointerHandlers.pointermove
1245
+ : this._pointerHandlers[type];
1141
1246
  if (handler) {
1142
1247
  this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1143
1248
  }
1144
1249
  }
1145
1250
  }
1146
1251
  _onPointerListenerRemoved(type) {
1147
- const hasListeners = Array.from(this.querySelectorAll('pc-entity'))
1148
- .some(entity => entity.hasListeners(type));
1252
+ const hasListeners = Array.from(this.querySelectorAll('pc-entity, pc-node')).some((entity) => entity._hasListeners(type));
1149
1253
  if (!hasListeners && this._canvas) {
1150
1254
  this._hasPointerListeners[type] = false;
1151
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
1152
- this._pointerHandlers.pointermove :
1153
- this._pointerHandlers[type];
1255
+ const handler = type === 'pointerenter' || type === 'pointerleave'
1256
+ ? this._pointerHandlers.pointermove
1257
+ : this._pointerHandlers[type];
1154
1258
  if (handler) {
1155
1259
  this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1156
1260
  }
@@ -1231,14 +1335,6 @@ class AppElement extends AsyncElement {
1231
1335
  get depthBuffer() {
1232
1336
  return this._depthBuffer;
1233
1337
  }
1234
- /**
1235
- * Gets the hierarchy ready flag.
1236
- * @returns The hierarchy ready flag.
1237
- * @ignore
1238
- */
1239
- get hierarchyReady() {
1240
- return this._hierarchyReady;
1241
- }
1242
1338
  /**
1243
1339
  * Sets whether the application shows its built-in loading bar while it boots and preloads its
1244
1340
  * assets. Enabled by default; setting `false` removes the bar immediately, while setting
@@ -1331,6 +1427,125 @@ class AppElement extends AsyncElement {
1331
1427
  }
1332
1428
  customElements.define('pc-app', AppElement);
1333
1429
 
1430
+ /**
1431
+ * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1432
+ * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1433
+ * @ignore
1434
+ */
1435
+ const POINTER_ATTRIBUTES = [
1436
+ 'onpointerenter',
1437
+ 'onpointerleave',
1438
+ 'onpointerdown',
1439
+ 'onpointerup',
1440
+ 'onpointermove'
1441
+ ];
1442
+ /**
1443
+ * The base class for elements that front an engine {@link Entity}: `<pc-entity>`, which creates
1444
+ * one, and `<pc-node>`, which binds to one inside a model's instantiated hierarchy. It carries
1445
+ * what both need — the `entity` contract, registration with the owning application (which joins
1446
+ * picked scene nodes back to elements by identity, never by name), and the pointer listener
1447
+ * bookkeeping that lets the application lazily attach its canvas handlers.
1448
+ */
1449
+ class EntityBaseElement extends AsyncElement {
1450
+ _entity = null;
1451
+ /**
1452
+ * The application element this entity is registered with, cached at registration time so the
1453
+ * entity can be unregistered even once this element has left the DOM.
1454
+ */
1455
+ _appElement = null;
1456
+ /**
1457
+ * The pointer event listeners for the entity.
1458
+ */
1459
+ _listeners = {};
1460
+ /**
1461
+ * The event types for which an inline `onpointer*` attribute is currently present.
1462
+ */
1463
+ _inlineHandlerTypes = new Set();
1464
+ /**
1465
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once the
1466
+ * entity is gone — await {@link whenReady} or the element's `ready()` promise before
1467
+ * accessing it.
1468
+ * @returns The entity instance, or `null`.
1469
+ */
1470
+ get entity() {
1471
+ return this._entity;
1472
+ }
1473
+ /**
1474
+ * Registers `entity` as this element's backing entity with the owning application, which
1475
+ * joins engine nodes back to elements by identity (never by name).
1476
+ *
1477
+ * @param entity - The entity to register.
1478
+ */
1479
+ _registerEntity(entity) {
1480
+ this._appElement = this.closestApp;
1481
+ this._appElement?._registerEntityElement(entity, this);
1482
+ }
1483
+ /**
1484
+ * Removes the registration for `entity`.
1485
+ *
1486
+ * @param entity - The entity to unregister.
1487
+ */
1488
+ _unregisterEntity(entity) {
1489
+ this._appElement?._unregisterEntityElement(entity);
1490
+ this._appElement = null;
1491
+ }
1492
+ /**
1493
+ * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
1494
+ * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
1495
+ * the previous handler and removing it removes the handler, exactly like `onclick` on any
1496
+ * HTML element. But because they bypass {@link addEventListener}, the connect/disconnect
1497
+ * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
1498
+ * kept in sync here.
1499
+ *
1500
+ * @param name - The attribute name (e.g. 'onpointerdown').
1501
+ * @param value - The attribute value, or `null` when the attribute has been removed.
1502
+ */
1503
+ _updateInlineHandler(name, value) {
1504
+ const type = name.substring(2);
1505
+ const had = this._inlineHandlerTypes.has(type);
1506
+ const has = value !== null;
1507
+ if (has && !had) {
1508
+ this._inlineHandlerTypes.add(type);
1509
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1510
+ }
1511
+ else if (!has && had) {
1512
+ this._inlineHandlerTypes.delete(type);
1513
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1514
+ }
1515
+ }
1516
+ addEventListener(type, listener, options) {
1517
+ if (!this._listeners[type]) {
1518
+ this._listeners[type] = [];
1519
+ }
1520
+ this._listeners[type].push(listener);
1521
+ super.addEventListener(type, listener, options);
1522
+ if (type.startsWith('pointer')) {
1523
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1524
+ }
1525
+ }
1526
+ removeEventListener(type, listener, options) {
1527
+ if (this._listeners[type]) {
1528
+ this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
1529
+ }
1530
+ super.removeEventListener(type, listener, options);
1531
+ if (type.startsWith('pointer')) {
1532
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1533
+ }
1534
+ }
1535
+ /**
1536
+ * Whether the element has a listener for an event type, registered either with
1537
+ * {@link addEventListener} or with the matching inline `onpointer*` attribute. Read by the
1538
+ * containing `<pc-app>` element to gate pointer event synthesis.
1539
+ *
1540
+ * @param type - The event type.
1541
+ * @returns Whether a listener is registered.
1542
+ * @internal
1543
+ */
1544
+ _hasListeners(type) {
1545
+ return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1546
+ }
1547
+ }
1548
+
1334
1549
  /**
1335
1550
  * The EntityElement interface provides properties and methods for manipulating
1336
1551
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-entity/ | `<pc-entity>`} elements.
@@ -1355,7 +1570,7 @@ customElements.define('pc-app', AppElement);
1355
1570
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the entity.
1356
1571
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the entity.
1357
1572
  */
1358
- class EntityElement extends AsyncElement {
1573
+ class EntityElement extends EntityBaseElement {
1359
1574
  /**
1360
1575
  * Whether the entity is enabled.
1361
1576
  */
@@ -1380,34 +1595,18 @@ class EntityElement extends AsyncElement {
1380
1595
  * The tags of the entity.
1381
1596
  */
1382
1597
  _tags = [];
1383
- /**
1384
- * The pointer event listeners for the entity.
1385
- */
1386
- _listeners = {};
1387
- /**
1388
- * The event types for which an inline `onpointer*` attribute is currently present.
1389
- */
1390
- _inlineHandlerTypes = new Set();
1391
1598
  /**
1392
1599
  * Whether the hierarchy has been built for this entity.
1393
1600
  */
1394
1601
  _built = false;
1395
- _entity = null;
1396
- /**
1397
- * The application element this entity is registered with, cached at creation time so the
1398
- * entity can be unregistered even once this element has left the DOM.
1399
- */
1400
- _appElement = null;
1401
1602
  /**
1402
- * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
1403
- * been removed from the document await {@link whenReady} or the element's `ready()`
1404
- * promise before accessing it.
1405
- * @returns The entity instance, or `null`.
1603
+ * Creates the backing entity. Called by the containing `<pc-app>` element during its boot
1604
+ * sweep, and on connection for elements inserted while the application is already running.
1605
+ *
1606
+ * @param app - The application to create the entity in.
1607
+ * @internal
1406
1608
  */
1407
- get entity() {
1408
- return this._entity;
1409
- }
1410
- createEntity(app) {
1609
+ _createEntity(app) {
1411
1610
  // Guard against double creation. When a subtree is inserted at runtime (e.g. cloning a
1412
1611
  // `<template>`), an ancestor's connectedCallback eagerly creates descendant entities; the
1413
1612
  // descendants' own connectedCallbacks would otherwise create them a second time.
@@ -1427,33 +1626,48 @@ class EntityElement extends AsyncElement {
1427
1626
  if (this._tags.length > 0) {
1428
1627
  entity.tags.add(this._tags);
1429
1628
  }
1430
- // Register with the owning application, which joins engine nodes back to elements by
1431
- // identity (never by name), and hook the entity's destruction. The engine fires 'destroy'
1432
- // for every entity in a destroyed subtree, so the element learns of its entity's death no
1433
- // matter who causes it: this element, an ancestor, the whole application, or a user
1434
- // script calling entity.destroy().
1435
- this._appElement = this.closestApp;
1436
- this._appElement?._registerEntityElement(entity, this);
1629
+ // Register with the owning application and hook the entity's destruction. The engine
1630
+ // fires 'destroy' for every entity in a destroyed subtree, so the element learns of its
1631
+ // entity's death no matter who causes it: this element, an ancestor, the whole
1632
+ // application, or a user script calling entity.destroy().
1633
+ this._registerEntity(entity);
1437
1634
  entity.once('destroy', this._onEntityDestroy, this);
1438
1635
  }
1439
1636
  /**
1440
1637
  * Handles the destruction of the backing entity. Resets the element so a later re-insertion
1441
- * starts clean: `_built` must be cleared alongside `_entity`, or buildHierarchy would bail
1442
- * and a re-created entity would never be parented.
1638
+ * starts clean: `_built` must be cleared alongside `_entity`, or _buildHierarchy would bail
1639
+ * and a re-created entity would never be parented. Readiness is re-armed for the same
1640
+ * reason — with the entity gone, a resolved ready promise would resume its awaiters against
1641
+ * a null `entity`.
1443
1642
  *
1444
1643
  * @param entity - The entity that was destroyed.
1445
1644
  */
1446
1645
  _onEntityDestroy(entity) {
1447
- this._appElement?._unregisterEntityElement(entity);
1448
- this._appElement = null;
1646
+ this._unregisterEntity(entity);
1449
1647
  this._entity = null;
1450
1648
  this._built = false;
1649
+ this._resetReady();
1451
1650
  }
1452
- buildHierarchy(app) {
1651
+ /**
1652
+ * Parents the backing entity: under the entity of the nearest ancestor `<pc-entity>` or
1653
+ * `<pc-node>` when there is one, and under the application root otherwise. Called by the
1654
+ * containing `<pc-app>` element once a sweep has created every entity, so a parent's
1655
+ * existence never depends on document order.
1656
+ *
1657
+ * @param app - The application whose root adopts parentless entities.
1658
+ * @internal
1659
+ */
1660
+ _buildHierarchy(app) {
1453
1661
  if (!this.entity || this._built)
1454
1662
  return;
1455
- this._built = true;
1456
1663
  const closestEntity = this.closestEntity;
1664
+ // A host element without an entity is an unresolved `<pc-node>`: building now would
1665
+ // mis-anchor this entity to the application root while the host is still resolving.
1666
+ // Stay unbuilt - the host drives this subtree itself once it binds.
1667
+ if (closestEntity && !closestEntity.entity) {
1668
+ return;
1669
+ }
1670
+ this._built = true;
1457
1671
  if (closestEntity?.entity) {
1458
1672
  closestEntity.entity.addChild(this.entity);
1459
1673
  }
@@ -1475,17 +1689,17 @@ class EntityElement extends AsyncElement {
1475
1689
  return;
1476
1690
  }
1477
1691
  // If app is already running, create entity immediately
1478
- if (closestApp.hierarchyReady) {
1692
+ if (closestApp._hierarchyReady) {
1479
1693
  const app = closestApp.app;
1480
- this.createEntity(app);
1481
- this.buildHierarchy(app);
1694
+ this._createEntity(app);
1695
+ this._buildHierarchy(app);
1482
1696
  // Handle any child entities that might exist
1483
1697
  const childEntities = this.querySelectorAll('pc-entity');
1484
1698
  childEntities.forEach((child) => {
1485
- child.createEntity(app);
1699
+ child._createEntity(app);
1486
1700
  });
1487
1701
  childEntities.forEach((child) => {
1488
- child.buildHierarchy(app);
1702
+ child._buildHierarchy(app);
1489
1703
  });
1490
1704
  }
1491
1705
  }
@@ -1599,44 +1813,8 @@ class EntityElement extends AsyncElement {
1599
1813
  get tags() {
1600
1814
  return this._tags;
1601
1815
  }
1602
- /**
1603
- * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
1604
- * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
1605
- * the previous handler and removing it removes the handler, exactly like `onclick` on any
1606
- * HTML element. But because they bypass {@link addEventListener}, the connect/disconnect
1607
- * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
1608
- * kept in sync here.
1609
- *
1610
- * @param name - The attribute name (e.g. 'onpointerdown').
1611
- * @param value - The attribute value, or `null` when the attribute has been removed.
1612
- */
1613
- _updateInlineHandler(name, value) {
1614
- const type = name.substring(2);
1615
- const had = this._inlineHandlerTypes.has(type);
1616
- const has = value !== null;
1617
- if (has && !had) {
1618
- this._inlineHandlerTypes.add(type);
1619
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1620
- }
1621
- else if (!has && had) {
1622
- this._inlineHandlerTypes.delete(type);
1623
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1624
- }
1625
- }
1626
1816
  static get observedAttributes() {
1627
- return [
1628
- 'enabled',
1629
- 'name',
1630
- 'position',
1631
- 'rotation',
1632
- 'scale',
1633
- 'tags',
1634
- 'onpointerenter',
1635
- 'onpointerleave',
1636
- 'onpointerdown',
1637
- 'onpointerup',
1638
- 'onpointermove'
1639
- ];
1817
+ return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
1640
1818
  }
1641
1819
  attributeChangedCallback(name, _oldValue, newValue) {
1642
1820
  switch (name) {
@@ -1667,28 +1845,6 @@ class EntityElement extends AsyncElement {
1667
1845
  break;
1668
1846
  }
1669
1847
  }
1670
- addEventListener(type, listener, options) {
1671
- if (!this._listeners[type]) {
1672
- this._listeners[type] = [];
1673
- }
1674
- this._listeners[type].push(listener);
1675
- super.addEventListener(type, listener, options);
1676
- if (type.startsWith('pointer')) {
1677
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1678
- }
1679
- }
1680
- removeEventListener(type, listener, options) {
1681
- if (this._listeners[type]) {
1682
- this._listeners[type] = this._listeners[type].filter(l => l !== listener);
1683
- }
1684
- super.removeEventListener(type, listener, options);
1685
- if (type.startsWith('pointer')) {
1686
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1687
- }
1688
- }
1689
- hasListeners(type) {
1690
- return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1691
- }
1692
1848
  }
1693
1849
  customElements.define('pc-entity', EntityElement);
1694
1850
 
@@ -1873,6 +2029,48 @@ const renderModes = new Map([
1873
2029
  ['sliced', playcanvas.SPRITE_RENDERMODE_SLICED],
1874
2030
  ['tiled', playcanvas.SPRITE_RENDERMODE_TILED]
1875
2031
  ]);
2032
+ const addressModes = new Map([
2033
+ ['repeat', playcanvas.ADDRESS_REPEAT],
2034
+ ['clamp', playcanvas.ADDRESS_CLAMP_TO_EDGE],
2035
+ ['mirror', playcanvas.ADDRESS_MIRRORED_REPEAT]
2036
+ ]);
2037
+ const minFilterModes = new Map([
2038
+ ['nearest', playcanvas.FILTER_NEAREST],
2039
+ ['linear', playcanvas.FILTER_LINEAR],
2040
+ ['nearest-mip-nearest', playcanvas.FILTER_NEAREST_MIPMAP_NEAREST],
2041
+ ['linear-mip-nearest', playcanvas.FILTER_LINEAR_MIPMAP_NEAREST],
2042
+ ['nearest-mip-linear', playcanvas.FILTER_NEAREST_MIPMAP_LINEAR],
2043
+ ['linear-mip-linear', playcanvas.FILTER_LINEAR_MIPMAP_LINEAR]
2044
+ ]);
2045
+ const magFilterModes = new Map([
2046
+ ['nearest', playcanvas.FILTER_NEAREST],
2047
+ ['linear', playcanvas.FILTER_LINEAR]
2048
+ ]);
2049
+ // The engine's texture JSON spells the filter names with underscores ('linear_mip_linear'); the
2050
+ // attribute values are kebab-case like every other enum attribute in this library. The address
2051
+ // mode names contain no dashes, so for them the rename is the identity.
2052
+ const toTextureJson = (name) => name.replace(/-/g, '_');
2053
+ // Engine Texture constructor defaults, restored on a loaded texture when a texture option
2054
+ // attribute is removed.
2055
+ const textureOptionDefaults = {
2056
+ addressU: playcanvas.ADDRESS_REPEAT,
2057
+ addressV: playcanvas.ADDRESS_REPEAT,
2058
+ anisotropy: 1,
2059
+ flipY: false,
2060
+ magFilter: playcanvas.FILTER_LINEAR,
2061
+ minFilter: playcanvas.FILTER_LINEAR_MIPMAP_LINEAR,
2062
+ mipmaps: true,
2063
+ srgb: false
2064
+ };
2065
+ // Attributes that only apply to certain asset types, used to warn when one is set on an asset of
2066
+ // any other type (where it would otherwise be silently ignored).
2067
+ const typeScopedAttributes = [
2068
+ [
2069
+ ['address-u', 'address-v', 'anisotropy', 'flip-y', 'mag-filter', 'min-filter', 'mipmaps', 'srgb'],
2070
+ ['texture', 'textureatlas']
2071
+ ],
2072
+ [['atlas', 'frame-keys', 'pixels-per-unit', 'render-mode'], ['sprite']]
2073
+ ];
1876
2074
  const extToType = new Map([
1877
2075
  ['bin', 'binary'],
1878
2076
  ['css', 'css'],
@@ -1929,8 +2127,16 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
1929
2127
  * immediately unless `lazy`. A `pc-asset` must be a direct child of `pc-app` — elements placed
1930
2128
  * elsewhere, or with an unsupported asset type, never become ready.
1931
2129
  *
1932
- * Apart from `lazy`, these attributes are read once when the asset is created, so changing them
1933
- * later has no effect.
2130
+ * For `texture` and `textureatlas` assets, the texture options (`address-u`, `address-v`,
2131
+ * `min-filter`, `mag-filter`, `anisotropy`, `mipmaps`, `srgb`, `flip-y`) apply when the texture is
2132
+ * created and — like `lazy` — are observed: changing one updates a texture that has already
2133
+ * loaded, and removing one restores the engine default. Changing `srgb` or `mipmaps` on a loaded
2134
+ * texture recreates the underlying GPU resource, so prefer declaring those up front. Each option
2135
+ * overrides the matching key in the `data` JSON; options left unset write nothing, leaving the
2136
+ * engine's per-format defaults in force.
2137
+ *
2138
+ * Apart from `lazy` and the texture options, these attributes are read once when the asset is
2139
+ * created, so changing them later has no effect.
1934
2140
  *
1935
2141
  * @attribute {string} id - The identifier used to reference the asset from other elements.
1936
2142
  * @attribute {string} src - The URL of the asset to load.
@@ -1952,7 +2158,15 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
1952
2158
  * not that it succeeded.
1953
2159
  */
1954
2160
  class AssetElement extends AsyncElement {
2161
+ _addressU = null;
2162
+ _addressV = null;
2163
+ _anisotropy = null;
2164
+ _flipY = null;
1955
2165
  _lazy = false;
2166
+ _magFilter = null;
2167
+ _minFilter = null;
2168
+ _mipmaps = null;
2169
+ _srgb = null;
1956
2170
  /**
1957
2171
  * The asset that is loaded. Available once the element is ready — await
1958
2172
  * {@link whenReady} or the element's `ready()` promise before accessing it.
@@ -1977,7 +2191,7 @@ class AssetElement extends AsyncElement {
1977
2191
  const app = appElement.app;
1978
2192
  if (!app)
1979
2193
  return; // pc-app is re-connecting; its own boot will create this asset
1980
- this.createAsset();
2194
+ this._createAsset();
1981
2195
  if (this.asset) {
1982
2196
  app.assets.add(this.asset); // add() auto-loads when preload is true
1983
2197
  if (!this.lazy) {
@@ -1985,13 +2199,15 @@ class AssetElement extends AsyncElement {
1985
2199
  }
1986
2200
  }
1987
2201
  }
1988
- // Never ready if createAsset failed (unsupported asset type)
2202
+ // Never ready if _createAsset failed (unsupported asset type)
1989
2203
  if (this.asset) {
1990
2204
  this._onReady();
1991
2205
  }
1992
2206
  }
1993
2207
  disconnectedCallback() {
1994
- this.destroyAsset();
2208
+ this._destroyAsset();
2209
+ // Re-arm readiness so a re-inserted element announces the asset it creates then
2210
+ this._resetReady();
1995
2211
  }
1996
2212
  _onAssetLoad() {
1997
2213
  this.dispatchEvent(new Event('load'));
@@ -2001,7 +2217,14 @@ class AssetElement extends AsyncElement {
2001
2217
  message: err instanceof Error ? err.message : String(err)
2002
2218
  }));
2003
2219
  }
2004
- createAsset() {
2220
+ /**
2221
+ * Creates the asset from the element's attributes. Called by the containing `<pc-app>`
2222
+ * element during its boot sweep, and on connection for elements inserted while the
2223
+ * application is already running.
2224
+ *
2225
+ * @internal
2226
+ */
2227
+ _createAsset() {
2005
2228
  const id = this.getAttribute('id') || '';
2006
2229
  const src = this.getAttribute('src') || '';
2007
2230
  let type = this.getAttribute('type');
@@ -2014,6 +2237,15 @@ class AssetElement extends AsyncElement {
2014
2237
  console.warn(`Unsupported asset type: ${src}`);
2015
2238
  return;
2016
2239
  }
2240
+ // Attributes scoped to other asset types have no effect here - say so rather than
2241
+ // failing silently.
2242
+ const inapplicable = typeScopedAttributes
2243
+ .filter(([, types]) => !types.includes(type))
2244
+ .flatMap(([attributes]) => attributes)
2245
+ .filter((attribute) => this.hasAttribute(attribute));
2246
+ if (inapplicable.length > 0) {
2247
+ console.warn(`pc-asset '${id || src}' has attributes that do not apply to asset type '${type}' and are ignored: ${inapplicable.join(', ')}`);
2248
+ }
2017
2249
  // Optional inline asset data, used by data-driven assets such as texture atlases (frame
2018
2250
  // definitions) and sprites (atlas reference, frame keys, etc.).
2019
2251
  const data = this._buildData(type);
@@ -2041,9 +2273,11 @@ class AssetElement extends AsyncElement {
2041
2273
  this.asset.on('error', this._onAssetError, this);
2042
2274
  }
2043
2275
  /**
2044
- * Builds the `data` object for the asset from an optional inline `data` attribute (JSON) and,
2045
- * for sprites, from the convenience attributes (`atlas`, `frame-keys`, `pixels-per-unit`,
2046
- * `render-mode`). Returns `undefined` when there is no data to apply.
2276
+ * Builds the `data` object for the asset from an optional inline `data` attribute (JSON), the
2277
+ * texture option attributes (for `texture` and `textureatlas` assets), and the sprite
2278
+ * convenience attributes (`atlas`, `frame-keys`, `pixels-per-unit`, `render-mode`). An
2279
+ * attribute overrides the matching `data` JSON key. Returns `undefined` when there is no data
2280
+ * to apply.
2047
2281
  * @param type - The resolved asset type.
2048
2282
  * @returns The asset data, or `undefined`.
2049
2283
  */
@@ -2058,6 +2292,37 @@ class AssetElement extends AsyncElement {
2058
2292
  console.warn(`Invalid 'data' JSON on pc-asset: ${dataAttr}`);
2059
2293
  }
2060
2294
  }
2295
+ if (type === 'texture' || type === 'textureatlas') {
2296
+ data = data ?? {};
2297
+ // Only options the user actually set are written: the engine reads these keys with
2298
+ // hasOwnProperty semantics, and an absent key leaves its per-format default (an HDR's
2299
+ // 'rgbe' type, a KTX2's transcoded format) in force.
2300
+ if (this._addressU !== null) {
2301
+ data.addressu = this._addressU;
2302
+ }
2303
+ if (this._addressV !== null) {
2304
+ data.addressv = this._addressV;
2305
+ }
2306
+ if (this._anisotropy !== null) {
2307
+ data.anisotropy = this._anisotropy;
2308
+ }
2309
+ if (this._flipY !== null) {
2310
+ // 'flipY' is the one camelCase key in the engine's texture JSON
2311
+ data.flipY = this._flipY;
2312
+ }
2313
+ if (this._magFilter !== null) {
2314
+ data.magfilter = toTextureJson(this._magFilter);
2315
+ }
2316
+ if (this._minFilter !== null) {
2317
+ data.minfilter = toTextureJson(this._minFilter);
2318
+ }
2319
+ if (this._mipmaps !== null) {
2320
+ data.mipmaps = this._mipmaps;
2321
+ }
2322
+ if (this._srgb !== null) {
2323
+ data.srgb = this._srgb;
2324
+ }
2325
+ }
2061
2326
  if (type === 'sprite') {
2062
2327
  data = data ?? {};
2063
2328
  // Resolve the referenced texture atlas to its (numeric) asset id. The atlas must be
@@ -2091,7 +2356,57 @@ class AssetElement extends AsyncElement {
2091
2356
  }
2092
2357
  return data;
2093
2358
  }
2094
- destroyAsset() {
2359
+ /**
2360
+ * Returns the engine texture behind this asset, when there is one: the resource itself for a
2361
+ * `texture` asset, the atlas's texture for a `textureatlas` asset, `null` otherwise
2362
+ * (including before the asset has loaded).
2363
+ * @returns The texture, or `null`.
2364
+ */
2365
+ _texture() {
2366
+ const asset = this.asset;
2367
+ if (!asset?.resource)
2368
+ return null;
2369
+ if (asset.type === 'texture')
2370
+ return asset.resource;
2371
+ if (asset.type === 'textureatlas')
2372
+ return asset.resource.texture ?? null;
2373
+ return null;
2374
+ }
2375
+ /**
2376
+ * Writes one texture option through to the created asset, if any. The engine-JSON key is
2377
+ * written into `asset.data`, mutated in place - replacing the whole object would make the
2378
+ * registry re-patch every key, and a re-patched `srgb` or `mipmaps` recreates the texture
2379
+ * even when unchanged. The in-place key is what a not-yet-started load reads at texture
2380
+ * construction, and what any later reload reads. When the texture already exists, the
2381
+ * corresponding property is assigned directly; `null` (attribute removed) deletes the key
2382
+ * and restores the engine default. Assets of any other type are left untouched.
2383
+ *
2384
+ * @param key - The engine texture JSON key in `asset.data`.
2385
+ * @param property - The Texture property to assign.
2386
+ * @param dataValue - The engine-JSON value for `asset.data`, or `null` to delete the key.
2387
+ * @param textureValue - The value for the Texture property, or `null` for the engine default.
2388
+ */
2389
+ _applyTextureOption(key, property, dataValue, textureValue) {
2390
+ const asset = this.asset;
2391
+ if (!asset || (asset.type !== 'texture' && asset.type !== 'textureatlas'))
2392
+ return;
2393
+ const data = asset.data;
2394
+ if (dataValue === null) {
2395
+ delete data[key];
2396
+ }
2397
+ else {
2398
+ data[key] = dataValue;
2399
+ }
2400
+ const texture = this._texture();
2401
+ if (texture) {
2402
+ // Every option here is a number- or boolean-valued Texture property; the
2403
+ // value/property pairing is fixed by the callers, which TypeScript cannot see
2404
+ // through the union.
2405
+ texture[property] =
2406
+ textureValue ?? textureOptionDefaults[property];
2407
+ }
2408
+ }
2409
+ _destroyAsset() {
2095
2410
  if (this.asset) {
2096
2411
  // A caller that keeps the Asset alive must not dispatch on a removed element
2097
2412
  this.asset.off('load', this._onAssetLoad, this);
@@ -2103,32 +2418,261 @@ class AssetElement extends AsyncElement {
2103
2418
  }
2104
2419
  }
2105
2420
  /**
2106
- * Sets whether the asset should be loaded lazily.
2107
- * @param value - The lazy loading flag.
2421
+ * Sets the texture's horizontal (U) address mode: how texture coordinates outside the 0 to 1
2422
+ * range sample the texture. Applies to `texture` and `textureatlas` assets, both when the
2423
+ * texture is created and after it has loaded.
2424
+ * @param value - The address mode, or `null` to use the engine default of 'repeat'.
2108
2425
  */
2109
- set lazy(value) {
2110
- this._lazy = value;
2111
- if (this.asset) {
2112
- this.asset.preload = !value;
2113
- }
2426
+ set addressU(value) {
2427
+ this._addressU = value;
2428
+ const constant = value === null ? null : (addressModes.get(value) ?? playcanvas.ADDRESS_REPEAT);
2429
+ this._applyTextureOption('addressu', 'addressU', value, constant);
2114
2430
  }
2115
2431
  /**
2116
- * Gets whether the asset should be loaded lazily.
2117
- * @returns The lazy loading flag.
2432
+ * Gets the texture's horizontal (U) address mode.
2433
+ * @returns The address mode, or `null` when unset.
2118
2434
  */
2119
- get lazy() {
2120
- return this._lazy;
2435
+ get addressU() {
2436
+ return this._addressU;
2121
2437
  }
2122
- static get(id) {
2123
- const assetElement = document.querySelector(`pc-asset[id="${id}"]`);
2124
- return assetElement?.asset;
2438
+ /**
2439
+ * Sets the texture's vertical (V) address mode: how texture coordinates outside the 0 to 1
2440
+ * range sample the texture. Applies to `texture` and `textureatlas` assets, both when the
2441
+ * texture is created and after it has loaded.
2442
+ * @param value - The address mode, or `null` to use the engine default of 'repeat'.
2443
+ */
2444
+ set addressV(value) {
2445
+ this._addressV = value;
2446
+ const constant = value === null ? null : (addressModes.get(value) ?? playcanvas.ADDRESS_REPEAT);
2447
+ this._applyTextureOption('addressv', 'addressV', value, constant);
2125
2448
  }
2126
- static get observedAttributes() {
2127
- return ['lazy'];
2449
+ /**
2450
+ * Gets the texture's vertical (V) address mode.
2451
+ * @returns The address mode, or `null` when unset.
2452
+ */
2453
+ get addressV() {
2454
+ return this._addressV;
2128
2455
  }
2129
- attributeChangedCallback(name, _oldValue, newValue) {
2130
- if (name === 'lazy') {
2131
- this.lazy = parseBool(newValue, false);
2456
+ /**
2457
+ * Sets the texture's maximum anisotropic filtering level, which improves quality at oblique
2458
+ * viewing angles. Applies to `texture` and `textureatlas` assets, both when the texture is
2459
+ * created and after it has loaded.
2460
+ * @param value - The anisotropy level, or `null` to use the engine default of 1.
2461
+ */
2462
+ set anisotropy(value) {
2463
+ this._anisotropy = value;
2464
+ this._applyTextureOption('anisotropy', 'anisotropy', value, value);
2465
+ }
2466
+ /**
2467
+ * Gets the texture's maximum anisotropic filtering level.
2468
+ * @returns The anisotropy level, or `null` when unset.
2469
+ */
2470
+ get anisotropy() {
2471
+ return this._anisotropy;
2472
+ }
2473
+ /**
2474
+ * Sets whether the texture's image data is flipped vertically at upload. Applies to `texture`
2475
+ * and `textureatlas` assets, both when the texture is created and after it has loaded.
2476
+ * @param value - The flip flag, or `null` to use the engine default of `false`.
2477
+ */
2478
+ set flipY(value) {
2479
+ this._flipY = value;
2480
+ this._applyTextureOption('flipY', 'flipY', value, value);
2481
+ }
2482
+ /**
2483
+ * Gets whether the texture's image data is flipped vertically at upload.
2484
+ * @returns The flip flag, or `null` when unset.
2485
+ */
2486
+ get flipY() {
2487
+ return this._flipY;
2488
+ }
2489
+ /**
2490
+ * Sets whether the asset should be loaded lazily.
2491
+ * @param value - The lazy loading flag.
2492
+ */
2493
+ set lazy(value) {
2494
+ this._lazy = value;
2495
+ if (this.asset) {
2496
+ this.asset.preload = !value;
2497
+ }
2498
+ }
2499
+ /**
2500
+ * Gets whether the asset should be loaded lazily.
2501
+ * @returns The lazy loading flag.
2502
+ */
2503
+ get lazy() {
2504
+ return this._lazy;
2505
+ }
2506
+ /**
2507
+ * Sets the texture's magnification filter, used when the texture is displayed larger than its
2508
+ * source size. Applies to `texture` and `textureatlas` assets, both when the texture is
2509
+ * created and after it has loaded.
2510
+ * @param value - The filter, or `null` to use the engine default of 'linear'.
2511
+ */
2512
+ set magFilter(value) {
2513
+ this._magFilter = value;
2514
+ const json = value === null ? null : toTextureJson(value);
2515
+ const constant = value === null ? null : (magFilterModes.get(value) ?? playcanvas.FILTER_LINEAR);
2516
+ this._applyTextureOption('magfilter', 'magFilter', json, constant);
2517
+ }
2518
+ /**
2519
+ * Gets the texture's magnification filter.
2520
+ * @returns The filter, or `null` when unset.
2521
+ */
2522
+ get magFilter() {
2523
+ return this._magFilter;
2524
+ }
2525
+ /**
2526
+ * Sets the texture's minification filter, used when the texture is displayed smaller than its
2527
+ * source size. The mip variants blend within (and, for the second `linear`, between) mipmap
2528
+ * levels. Applies to `texture` and `textureatlas` assets, both when the texture is created
2529
+ * and after it has loaded.
2530
+ * @param value - The filter, or `null` to use the engine default of 'linear-mip-linear'.
2531
+ */
2532
+ set minFilter(value) {
2533
+ this._minFilter = value;
2534
+ const json = value === null ? null : toTextureJson(value);
2535
+ const constant = value === null ? null : (minFilterModes.get(value) ?? playcanvas.FILTER_LINEAR_MIPMAP_LINEAR);
2536
+ this._applyTextureOption('minfilter', 'minFilter', json, constant);
2537
+ }
2538
+ /**
2539
+ * Gets the texture's minification filter.
2540
+ * @returns The filter, or `null` when unset.
2541
+ */
2542
+ get minFilter() {
2543
+ return this._minFilter;
2544
+ }
2545
+ /**
2546
+ * Sets whether the texture generates and uses mipmaps. Changing this on a loaded texture
2547
+ * recreates the underlying GPU resource, so prefer declaring it up front. Applies to
2548
+ * `texture` and `textureatlas` assets.
2549
+ * @param value - The mipmaps flag, or `null` to use the engine default of `true`.
2550
+ */
2551
+ set mipmaps(value) {
2552
+ this._mipmaps = value;
2553
+ this._applyTextureOption('mipmaps', 'mipmaps', value, value);
2554
+ }
2555
+ /**
2556
+ * Gets whether the texture generates and uses mipmaps.
2557
+ * @returns The mipmaps flag, or `null` when unset.
2558
+ */
2559
+ get mipmaps() {
2560
+ return this._mipmaps;
2561
+ }
2562
+ /**
2563
+ * Sets whether the texture holds sRGB (gamma-encoded) color data, enabling hardware gamma
2564
+ * decode. Free when set before the texture loads; changing it on a loaded texture recreates
2565
+ * the underlying GPU resource. Applies to `texture` and `textureatlas` assets.
2566
+ * @param value - The sRGB flag, or `null` to use the engine default of `false`.
2567
+ */
2568
+ set srgb(value) {
2569
+ this._srgb = value;
2570
+ this._applyTextureOption('srgb', 'srgb', value, value);
2571
+ }
2572
+ /**
2573
+ * Gets whether the texture holds sRGB (gamma-encoded) color data.
2574
+ * @returns The sRGB flag, or `null` when unset.
2575
+ */
2576
+ get srgb() {
2577
+ return this._srgb;
2578
+ }
2579
+ /**
2580
+ * Returns the {@link Asset} created by the `<pc-asset>` element with the given `id`, or
2581
+ * `undefined` if there is no such element or its asset has not been created yet.
2582
+ *
2583
+ * @param id - The `id` of the `<pc-asset>` element.
2584
+ * @returns The asset, or `undefined`.
2585
+ */
2586
+ static get(id) {
2587
+ const assetElement = document.querySelector(`pc-asset[id="${id}"]`);
2588
+ return assetElement?.asset;
2589
+ }
2590
+ static get observedAttributes() {
2591
+ return [
2592
+ 'address-u',
2593
+ 'address-v',
2594
+ 'anisotropy',
2595
+ 'flip-y',
2596
+ 'lazy',
2597
+ 'mag-filter',
2598
+ 'min-filter',
2599
+ 'mipmaps',
2600
+ 'srgb'
2601
+ ];
2602
+ }
2603
+ attributeChangedCallback(name, _oldValue, newValue) {
2604
+ // Each texture option keeps its parse* call as the branch's first assignment (the CEM
2605
+ // manifest derives the attribute's type and default from it - a ternary would degrade
2606
+ // both to plain string) and treats a removed attribute (null) as a reset to unset,
2607
+ // which restores the engine default on a loaded texture.
2608
+ switch (name) {
2609
+ case 'address-u':
2610
+ if (newValue !== null) {
2611
+ this.addressU = parseEnum(newValue, addressModes, 'repeat', name);
2612
+ }
2613
+ else {
2614
+ this.addressU = null;
2615
+ }
2616
+ break;
2617
+ case 'address-v':
2618
+ if (newValue !== null) {
2619
+ this.addressV = parseEnum(newValue, addressModes, 'repeat', name);
2620
+ }
2621
+ else {
2622
+ this.addressV = null;
2623
+ }
2624
+ break;
2625
+ case 'anisotropy':
2626
+ if (newValue !== null) {
2627
+ this.anisotropy = parseNumber(newValue, 1, name);
2628
+ }
2629
+ else {
2630
+ this.anisotropy = null;
2631
+ }
2632
+ break;
2633
+ case 'flip-y':
2634
+ if (newValue !== null) {
2635
+ this.flipY = parseBool(newValue, false);
2636
+ }
2637
+ else {
2638
+ this.flipY = null;
2639
+ }
2640
+ break;
2641
+ case 'lazy':
2642
+ this.lazy = parseBool(newValue, false);
2643
+ break;
2644
+ case 'mag-filter':
2645
+ if (newValue !== null) {
2646
+ this.magFilter = parseEnum(newValue, magFilterModes, 'linear', name);
2647
+ }
2648
+ else {
2649
+ this.magFilter = null;
2650
+ }
2651
+ break;
2652
+ case 'min-filter':
2653
+ if (newValue !== null) {
2654
+ this.minFilter = parseEnum(newValue, minFilterModes, 'linear-mip-linear', name);
2655
+ }
2656
+ else {
2657
+ this.minFilter = null;
2658
+ }
2659
+ break;
2660
+ case 'mipmaps':
2661
+ if (newValue !== null) {
2662
+ this.mipmaps = parseBool(newValue, true);
2663
+ }
2664
+ else {
2665
+ this.mipmaps = null;
2666
+ }
2667
+ break;
2668
+ case 'srgb':
2669
+ if (newValue !== null) {
2670
+ this.srgb = parseBool(newValue, false);
2671
+ }
2672
+ else {
2673
+ this.srgb = null;
2674
+ }
2675
+ break;
2132
2676
  }
2133
2677
  }
2134
2678
  }
@@ -2144,6 +2688,24 @@ class ComponentElement extends AsyncElement {
2144
2688
  _enabled = true;
2145
2689
  _component = null;
2146
2690
  _appElement = null;
2691
+ /**
2692
+ * The element hosting this component, held so the host's readiness cycles can be observed
2693
+ * even after `closestEntity` would no longer resolve (during teardown).
2694
+ */
2695
+ _hostElement = null;
2696
+ /**
2697
+ * The listener re-applying this component when the host's readiness cycles. Held for
2698
+ * removal on disconnect.
2699
+ */
2700
+ _hostReadyListener = null;
2701
+ /**
2702
+ * Incremented on every connect and disconnect. connectedCallback captures the value on entry
2703
+ * and abandons itself wherever it resumes from an await if the value has moved on — so a
2704
+ * callback whose element was removed cannot act on a torn-down tree, and one whose element
2705
+ * was removed and re-inserted (which runs a callback of its own) cannot add the component a
2706
+ * second time.
2707
+ */
2708
+ _connectionGeneration = 0;
2147
2709
  /**
2148
2710
  * Creates a new ComponentElement instance.
2149
2711
  *
@@ -2154,11 +2716,48 @@ class ComponentElement extends AsyncElement {
2154
2716
  super();
2155
2717
  this._componentName = componentName;
2156
2718
  }
2157
- // Method to be overridden by subclasses to provide initial component data
2719
+ /**
2720
+ * Returns the data the component is created with. Overridden by subclasses to supply the
2721
+ * initial values of their cached properties.
2722
+ *
2723
+ * @returns The initial component data.
2724
+ */
2158
2725
  getInitialComponentData() {
2159
2726
  return {};
2160
2727
  }
2161
- async addComponent() {
2728
+ /**
2729
+ * Creates the component on the host's current entity, removing it first from a previous
2730
+ * entity that is still alive (a retargeted `<pc-node>` moves its decorations with it). When
2731
+ * the entity already has a component of this type — a glTF node arriving with its authored
2732
+ * `render` component, say — warns and leaves `component` null. The element-level warning is
2733
+ * load-bearing: the engine's own duplicate-addComponent warning is Debug-stripped from
2734
+ * production builds, which would otherwise leave a silent null.
2735
+ */
2736
+ _applyComponent() {
2737
+ const entity = this._hostElement?.entity ?? null;
2738
+ if (this._component && this._component.entity === entity) {
2739
+ return;
2740
+ }
2741
+ // A retarget leaves the previous component on a still-live entity - remove it so the
2742
+ // decoration follows the element, or vanishes with a dissolved binding. A destroyed
2743
+ // entity took its components with it.
2744
+ const previous = this._component;
2745
+ if (previous?.entity && previous.entity.c[this._componentName] === previous) {
2746
+ previous.entity.removeComponent(this._componentName);
2747
+ }
2748
+ this._component = null;
2749
+ if (!entity) {
2750
+ return;
2751
+ }
2752
+ if (entity.c[this._componentName]) {
2753
+ const label = this.id ? ` '${this.id}'` : '';
2754
+ console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
2755
+ return;
2756
+ }
2757
+ this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
2758
+ }
2759
+ async _addComponent() {
2760
+ const generation = this._connectionGeneration;
2162
2761
  const entityElement = this.closestEntity;
2163
2762
  if (!entityElement) {
2164
2763
  // A component can only exist on an entity, so an element placed outside one is inert.
@@ -2168,19 +2767,78 @@ class ComponentElement extends AsyncElement {
2168
2767
  return;
2169
2768
  }
2170
2769
  await entityElement.ready();
2171
- // Add the component to the entity
2172
- const data = this.getInitialComponentData();
2173
- this._component = entityElement.entity.addComponent(this._componentName, data);
2770
+ // The element may have been removed, or removed and re-inserted, while the entity became
2771
+ // ready the component belongs to the connection that owns the current generation.
2772
+ if (generation !== this._connectionGeneration) {
2773
+ return;
2774
+ }
2775
+ this._hostElement = entityElement;
2776
+ this._applyComponent();
2777
+ // Re-apply when the host's readiness cycles without this element disconnecting: a
2778
+ // `<pc-node>` rebinding after its model reloads or retargets, or a re-created entity.
2779
+ // The 'ready' event bubbles, so events from descendants pass through this host - only
2780
+ // the host's own cycles count. Readiness is cycled here too, so decorations one level
2781
+ // down re-apply the same way.
2782
+ this._hostReadyListener = (event) => {
2783
+ if (event.target !== this._hostElement) {
2784
+ return;
2785
+ }
2786
+ if (generation !== this._connectionGeneration) {
2787
+ return;
2788
+ }
2789
+ this._hostCycled();
2790
+ };
2791
+ entityElement.addEventListener('ready', this._hostReadyListener);
2792
+ }
2793
+ /**
2794
+ * Re-evaluates this component against the host's current entity: applied to a new entity,
2795
+ * moved from a still-live old one, or removed when the host no longer fronts an entity at
2796
+ * all. Readiness follows - it cycles with a re-application and stays unresolved while the
2797
+ * host is unbound. Called by the host-ready listener, and directly by a `<pc-node>`
2798
+ * dissolving its binding: the one transition that fires no ready event to ride.
2799
+ *
2800
+ * @internal
2801
+ */
2802
+ _hostCycled() {
2803
+ this._resetReady();
2804
+ this._applyComponent();
2805
+ if (this._hostElement?.entity) {
2806
+ this.initComponent();
2807
+ this._onReady();
2808
+ }
2809
+ }
2810
+ /**
2811
+ * Configures the newly added component. Overridden by subclasses whose setup goes beyond
2812
+ * the initial data — child-element handling, asset resolution and the like.
2813
+ */
2814
+ initComponent() {
2815
+ // optional hook
2174
2816
  }
2175
- initComponent() { }
2176
2817
  async connectedCallback() {
2818
+ const generation = ++this._connectionGeneration;
2177
2819
  this._appElement = this.closestApp ?? null;
2178
2820
  await this._appElement?.ready();
2179
- await this.addComponent();
2821
+ // The element may have been removed, or removed and re-inserted, while the application
2822
+ // became ready. A re-insertion runs a connectedCallback of its own, so a stale resume
2823
+ // must not add the component alongside it.
2824
+ if (generation !== this._connectionGeneration) {
2825
+ return;
2826
+ }
2827
+ await this._addComponent();
2828
+ if (generation !== this._connectionGeneration) {
2829
+ return;
2830
+ }
2180
2831
  this.initComponent();
2181
2832
  this._onReady();
2182
2833
  }
2183
2834
  disconnectedCallback() {
2835
+ // Invalidate any connectedCallback still suspended on an await
2836
+ this._connectionGeneration++;
2837
+ if (this._hostElement && this._hostReadyListener) {
2838
+ this._hostElement.removeEventListener('ready', this._hostReadyListener);
2839
+ }
2840
+ this._hostElement = null;
2841
+ this._hostReadyListener = null;
2184
2842
  // Remove the component when the element is disconnected. Skip this when the owning
2185
2843
  // application has already been destroyed — removing a <pc-app> disconnects it before
2186
2844
  // its children, taking the component systems with it.
@@ -2189,6 +2847,7 @@ class ComponentElement extends AsyncElement {
2189
2847
  }
2190
2848
  this._component = null;
2191
2849
  this._appElement = null;
2850
+ this._resetReady();
2192
2851
  }
2193
2852
  /**
2194
2853
  * The PlayCanvas component instance. `null` until the element is ready, and also for an
@@ -3139,6 +3798,12 @@ customElements.define('pc-camera', CameraComponentElement);
3139
3798
  * The CollisionComponentElement interface also inherits the properties and methods of the
3140
3799
  * {@link HTMLElement} interface.
3141
3800
  *
3801
+ * For `type="mesh"`, the collision geometry defaults to the host entity's own render component
3802
+ * (its render asset) — a collider matching the visible mesh, which is what a mesh collider on a
3803
+ * glTF node means. The default resolves each time the component applies, so a `pc-node` that
3804
+ * retargets or rebinds picks up the new node's geometry. An entity with no asset-backed render
3805
+ * component warns, and the collider has no shape.
3806
+ *
3142
3807
  * @category Components
3143
3808
  */
3144
3809
  class CollisionComponentElement extends ComponentElement {
@@ -3166,6 +3831,29 @@ class CollisionComponentElement extends ComponentElement {
3166
3831
  type: this._type
3167
3832
  };
3168
3833
  }
3834
+ initComponent() {
3835
+ this._applyMeshGeometryDefault();
3836
+ }
3837
+ /**
3838
+ * Defaults a mesh collider's geometry to the host entity's own render component. The
3839
+ * engine's mesh collider only works with explicitly supplied geometry, and the element has
3840
+ * no attribute to supply it - so the host's visible geometry, the meaning a mesh collider
3841
+ * on a glTF node carries, fills the gap. Runs on every application (so a rebound `pc-node`
3842
+ * recomputes it) and on a runtime switch to `type="mesh"`; an explicitly assigned
3843
+ * `renderAsset` is never overwritten.
3844
+ */
3845
+ _applyMeshGeometryDefault() {
3846
+ const component = this.component;
3847
+ if (!component || this._type !== 'mesh' || component.renderAsset !== null) {
3848
+ return;
3849
+ }
3850
+ const asset = component.entity.render?.asset ?? null;
3851
+ if (asset === null) {
3852
+ console.warn(`pc-collision type="mesh" on '${component.entity.name}' found no asset-backed render component to take geometry from - collider has no shape`);
3853
+ return;
3854
+ }
3855
+ component.renderAsset = asset;
3856
+ }
3169
3857
  /**
3170
3858
  * Gets the underlying PlayCanvas collision component.
3171
3859
  * @returns The collision component.
@@ -3240,13 +3928,24 @@ class CollisionComponentElement extends ComponentElement {
3240
3928
  this._type = value;
3241
3929
  if (this.component) {
3242
3930
  this.component.type = value;
3931
+ this._applyMeshGeometryDefault();
3243
3932
  }
3244
3933
  }
3245
3934
  get type() {
3246
3935
  return this._type;
3247
3936
  }
3248
3937
  static get observedAttributes() {
3249
- return [...super.observedAttributes, 'angular-offset', 'axis', 'convex-hull', 'half-extents', 'height', 'linear-offset', 'radius', 'type'];
3938
+ return [
3939
+ ...super.observedAttributes,
3940
+ 'angular-offset',
3941
+ 'axis',
3942
+ 'convex-hull',
3943
+ 'half-extents',
3944
+ 'height',
3945
+ 'linear-offset',
3946
+ 'radius',
3947
+ 'type'
3948
+ ];
3250
3949
  }
3251
3950
  attributeChangedCallback(name, _oldValue, newValue) {
3252
3951
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -4951,7 +5650,7 @@ class ParticleSystemComponentElement extends ComponentElement {
4951
5650
  }
4952
5651
  // Set all the config properties on the component
4953
5652
  for (const key in resource) {
4954
- if (resource.hasOwnProperty(key)) {
5653
+ if (Object.hasOwn(resource, key)) {
4955
5654
  this.component[key] = resource[key];
4956
5655
  }
4957
5656
  }
@@ -5024,10 +5723,7 @@ class ParticleSystemComponentElement extends ComponentElement {
5024
5723
  }
5025
5724
  }
5026
5725
  static get observedAttributes() {
5027
- return [
5028
- ...super.observedAttributes,
5029
- 'asset'
5030
- ];
5726
+ return [...super.observedAttributes, 'asset'];
5031
5727
  }
5032
5728
  attributeChangedCallback(name, _oldValue, newValue) {
5033
5729
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -5187,7 +5883,7 @@ class MaterialElement extends HTMLElement {
5187
5883
  _twoSidedLighting = false;
5188
5884
  _useFog = true;
5189
5885
  _useLighting = true;
5190
- // Diverges from the engine default of false - see the class docblock and createMaterial()
5886
+ // Diverges from the engine default of false - see the class docblock and _createMaterial()
5191
5887
  _useMetalness = true;
5192
5888
  _useMetalnessSpecularColor = false;
5193
5889
  _useSkybox = true;
@@ -5200,6 +5896,10 @@ class MaterialElement extends HTMLElement {
5200
5896
  _mapHandles = new Map();
5201
5897
  _updateScheduled = false;
5202
5898
  _glossConflictWarned = false;
5899
+ /**
5900
+ * The material. `null` until the containing application has created it — an element present
5901
+ * at startup has its material once the application is ready.
5902
+ */
5203
5903
  material = null;
5204
5904
  async connectedCallback() {
5205
5905
  const appElement = this.parentElement?.closest('pc-app') ?? null;
@@ -5217,10 +5917,17 @@ class MaterialElement extends HTMLElement {
5217
5917
  if (!this.material) {
5218
5918
  if (!appElement.app)
5219
5919
  return; // pc-app is re-connecting; its own boot will create this
5220
- this.createMaterial();
5920
+ this._createMaterial();
5221
5921
  }
5222
5922
  }
5223
- createMaterial() {
5923
+ /**
5924
+ * Creates the material from the element's cached properties. Called by the containing
5925
+ * `<pc-app>` element during its boot sweep, and on connection for elements inserted while
5926
+ * the application is already running.
5927
+ *
5928
+ * @internal
5929
+ */
5930
+ _createMaterial() {
5224
5931
  const material = new playcanvas.StandardMaterial();
5225
5932
  this.material = material;
5226
5933
  material.alphaTest = this._alphaTest;
@@ -5347,9 +6054,9 @@ class MaterialElement extends HTMLElement {
5347
6054
  * warning latches and reports once per episode, clearing when the clash is resolved.
5348
6055
  */
5349
6056
  _warnGlossConflict() {
5350
- const quote = (names) => `'${names.join('\', \'')}'`;
5351
- const roughness = roughnessAliases.filter(name => this.hasAttribute(name));
5352
- const gloss = glossConflicts.filter(name => this.hasAttribute(name));
6057
+ const quote = (names) => `'${names.join("', '")}'`;
6058
+ const roughness = roughnessAliases.filter((name) => this.hasAttribute(name));
6059
+ const gloss = glossConflicts.filter((name) => this.hasAttribute(name));
5353
6060
  if (roughness.length === 0 || gloss.length === 0) {
5354
6061
  this._glossConflictWarned = false;
5355
6062
  return;
@@ -5367,7 +6074,7 @@ class MaterialElement extends HTMLElement {
5367
6074
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
5368
6075
  * @param slot - The material property to write.
5369
6076
  */
5370
- setMap(id, slot) {
6077
+ _setMap(id, slot) {
5371
6078
  // Drop any load still pending for this slot - its texture is no longer the one we want
5372
6079
  this._mapHandles.get(slot)?.off();
5373
6080
  this._mapHandles.delete(slot);
@@ -5392,13 +6099,13 @@ class MaterialElement extends HTMLElement {
5392
6099
  }
5393
6100
  /**
5394
6101
  * @param slot - The material property to write.
5395
- * @param texture - The loaded texture.
6102
+ * @param texture - The loaded texture, applied with its sampler state untouched - anisotropy
6103
+ * and friends belong to the `pc-asset`'s texture options.
5396
6104
  */
5397
6105
  _applyMap(slot, texture) {
5398
6106
  if (!this.material)
5399
6107
  return;
5400
6108
  this.material[slot] = texture;
5401
- texture.anisotropy = 4;
5402
6109
  this._scheduleUpdate();
5403
6110
  }
5404
6111
  /**
@@ -5461,7 +6168,7 @@ class MaterialElement extends HTMLElement {
5461
6168
  */
5462
6169
  set aoMap(value) {
5463
6170
  this._aoMap = value;
5464
- this.setMap(value, 'aoMap');
6171
+ this._setMap(value, 'aoMap');
5465
6172
  }
5466
6173
  /**
5467
6174
  * Gets the id of the `pc-asset` used as the ambient occlusion map.
@@ -5693,7 +6400,7 @@ class MaterialElement extends HTMLElement {
5693
6400
  */
5694
6401
  set diffuseMap(value) {
5695
6402
  this._diffuseMap = value;
5696
- this.setMap(value, 'diffuseMap');
6403
+ this._setMap(value, 'diffuseMap');
5697
6404
  }
5698
6405
  /**
5699
6406
  * Gets the id of the `pc-asset` used as the diffuse map.
@@ -5834,7 +6541,7 @@ class MaterialElement extends HTMLElement {
5834
6541
  */
5835
6542
  set emissiveMap(value) {
5836
6543
  this._emissiveMap = value;
5837
- this.setMap(value, 'emissiveMap');
6544
+ this._setMap(value, 'emissiveMap');
5838
6545
  }
5839
6546
  /**
5840
6547
  * Gets the id of the `pc-asset` used as the emissive map.
@@ -6012,7 +6719,7 @@ class MaterialElement extends HTMLElement {
6012
6719
  */
6013
6720
  set glossMap(value) {
6014
6721
  this._glossMap = value;
6015
- this.setMap(value, 'glossMap');
6722
+ this._setMap(value, 'glossMap');
6016
6723
  }
6017
6724
  /**
6018
6725
  * Gets the id of the `pc-asset` used as the gloss map.
@@ -6117,7 +6824,7 @@ class MaterialElement extends HTMLElement {
6117
6824
  */
6118
6825
  set heightMap(value) {
6119
6826
  this._heightMap = value;
6120
- this.setMap(value, 'heightMap');
6827
+ this._setMap(value, 'heightMap');
6121
6828
  }
6122
6829
  /**
6123
6830
  * Gets the id of the `pc-asset` used as the height map.
@@ -6258,7 +6965,7 @@ class MaterialElement extends HTMLElement {
6258
6965
  */
6259
6966
  set metalnessMap(value) {
6260
6967
  this._metalnessMap = value;
6261
- this.setMap(value, 'metalnessMap');
6968
+ this._setMap(value, 'metalnessMap');
6262
6969
  }
6263
6970
  /**
6264
6971
  * Gets the id of the `pc-asset` used as the metalness map.
@@ -6363,7 +7070,7 @@ class MaterialElement extends HTMLElement {
6363
7070
  */
6364
7071
  set normalMap(value) {
6365
7072
  this._normalMap = value;
6366
- this.setMap(value, 'normalMap');
7073
+ this._setMap(value, 'normalMap');
6367
7074
  }
6368
7075
  /**
6369
7076
  * Gets the id of the `pc-asset` used as the normal map.
@@ -6451,7 +7158,7 @@ class MaterialElement extends HTMLElement {
6451
7158
  set occludeDirect(value) {
6452
7159
  this._occludeDirect = value;
6453
7160
  if (this.material) {
6454
- // @ts-ignore see createMaterial() - the engine mistypes occludeDirect as a number
7161
+ // @ts-ignore see _createMaterial() - the engine mistypes occludeDirect as a number
6455
7162
  this.material.occludeDirect = value;
6456
7163
  this._scheduleUpdate();
6457
7164
  }
@@ -6543,7 +7250,7 @@ class MaterialElement extends HTMLElement {
6543
7250
  */
6544
7251
  set opacityMap(value) {
6545
7252
  this._opacityMap = value;
6546
- this.setMap(value, 'opacityMap');
7253
+ this._setMap(value, 'opacityMap');
6547
7254
  }
6548
7255
  /**
6549
7256
  * Gets the id of the `pc-asset` used as the opacity map.
@@ -6861,6 +7568,13 @@ class MaterialElement extends HTMLElement {
6861
7568
  get useTonemap() {
6862
7569
  return this._useTonemap;
6863
7570
  }
7571
+ /**
7572
+ * Returns the {@link StandardMaterial} created by the `<pc-material>` element with the given
7573
+ * `id`, or `undefined` if there is no such element or its material has not been created yet.
7574
+ *
7575
+ * @param id - The `id` of the `<pc-material>` element.
7576
+ * @returns The material, or `undefined`.
7577
+ */
6864
7578
  static get(id) {
6865
7579
  const materialElement = document.querySelector(`pc-material[id="${id}"]`);
6866
7580
  return materialElement?.material;
@@ -7499,7 +8213,18 @@ class RigidBodyComponentElement extends ComponentElement {
7499
8213
  return this._type;
7500
8214
  }
7501
8215
  static get observedAttributes() {
7502
- return [...super.observedAttributes, 'angular-damping', 'angular-factor', 'friction', 'linear-damping', 'linear-factor', 'mass', 'restitution', 'rolling-friction', 'type'];
8216
+ return [
8217
+ ...super.observedAttributes,
8218
+ 'angular-damping',
8219
+ 'angular-factor',
8220
+ 'friction',
8221
+ 'linear-damping',
8222
+ 'linear-factor',
8223
+ 'mass',
8224
+ 'restitution',
8225
+ 'rolling-friction',
8226
+ 'type'
8227
+ ];
7503
8228
  }
7504
8229
  attributeChangedCallback(name, _oldValue, newValue) {
7505
8230
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -7804,13 +8529,7 @@ class ScrollbarComponentElement extends ComponentElement {
7804
8529
  return this._handle;
7805
8530
  }
7806
8531
  static get observedAttributes() {
7807
- return [
7808
- ...super.observedAttributes,
7809
- 'orientation',
7810
- 'value',
7811
- 'handle-size',
7812
- 'handle'
7813
- ];
8532
+ return [...super.observedAttributes, 'orientation', 'value', 'handle-size', 'handle'];
7814
8533
  }
7815
8534
  attributeChangedCallback(name, _oldValue, newValue) {
7816
8535
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -8036,7 +8755,8 @@ class ScrollViewComponentElement extends ComponentElement {
8036
8755
  set horizontalScrollbarVisibility(value) {
8037
8756
  this._horizontalScrollbarVisibility = value;
8038
8757
  if (this.component) {
8039
- this.component.horizontalScrollbarVisibility = visibilities.get(value) ?? playcanvas.SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8758
+ this.component.horizontalScrollbarVisibility =
8759
+ visibilities.get(value) ?? playcanvas.SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8040
8760
  }
8041
8761
  }
8042
8762
  /**
@@ -8054,7 +8774,8 @@ class ScrollViewComponentElement extends ComponentElement {
8054
8774
  set verticalScrollbarVisibility(value) {
8055
8775
  this._verticalScrollbarVisibility = value;
8056
8776
  if (this.component) {
8057
- this.component.verticalScrollbarVisibility = visibilities.get(value) ?? playcanvas.SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8777
+ this.component.verticalScrollbarVisibility =
8778
+ visibilities.get(value) ?? playcanvas.SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8058
8779
  }
8059
8780
  }
8060
8781
  /**
@@ -8241,15 +8962,9 @@ customElements.define('pc-scrollview', ScrollViewComponentElement);
8241
8962
  class ScriptElement extends AsyncElement {
8242
8963
  _attributes = {};
8243
8964
  _enabled = true;
8244
- /**
8245
- * Whether readiness has been signalled. Creation can happen more than once over an
8246
- * element's life (a runtime `name` change recreates the instance), but `ready` is a
8247
- * one-shot signal, so only the first successful creation fires it.
8248
- */
8249
- _readySignalled = false;
8250
8965
  /**
8251
8966
  * The Script instance created for this element by its parent `<pc-scripts>` element.
8252
- * @ignore
8967
+ * @internal
8253
8968
  */
8254
8969
  _script = null;
8255
8970
  /**
@@ -8331,14 +9046,20 @@ class ScriptElement extends AsyncElement {
8331
9046
  console.warn(`pc-script '${this.getAttribute('name')}' must be a direct child of pc-scripts - script not created`);
8332
9047
  }
8333
9048
  }
9049
+ disconnectedCallback() {
9050
+ // Re-arm readiness so a re-inserted element announces the instance created for it then.
9051
+ // `_script` is deliberately NOT cleared here: the parent's mutation observer processes
9052
+ // this removal afterwards and reads it to establish which engine script this element
9053
+ // owned - the parent is what clears it.
9054
+ this._resetReady();
9055
+ }
8334
9056
  /**
8335
9057
  * Called by the parent `<pc-scripts>` element when the script instance has been created.
8336
- * @ignore
9058
+ * Creation can happen more than once per connection (a runtime `name` change recreates the
9059
+ * instance), but `_onReady` signals readiness at most once per cycle.
9060
+ * @internal
8337
9061
  */
8338
9062
  _onScriptCreated() {
8339
- if (this._readySignalled)
8340
- return;
8341
- this._readySignalled = true;
8342
9063
  this._onReady();
8343
9064
  }
8344
9065
  static get observedAttributes() {
@@ -8383,10 +9104,34 @@ customElements.define('pc-script', ScriptElement);
8383
9104
  */
8384
9105
  const RESERVED_ATTRIBUTES = new Set([
8385
9106
  ...ScriptElement.observedAttributes,
8386
- 'accesskey', 'autocapitalize', 'autofocus', 'class', 'contenteditable', 'dir', 'draggable',
8387
- 'exportparts', 'hidden', 'id', 'inert', 'is', 'itemid', 'itemprop', 'itemref', 'itemscope',
8388
- 'itemtype', 'lang', 'nonce', 'part', 'popover', 'role', 'slot', 'spellcheck', 'style',
8389
- 'tabindex', 'title', 'translate'
9107
+ 'accesskey',
9108
+ 'autocapitalize',
9109
+ 'autofocus',
9110
+ 'class',
9111
+ 'contenteditable',
9112
+ 'dir',
9113
+ 'draggable',
9114
+ 'exportparts',
9115
+ 'hidden',
9116
+ 'id',
9117
+ 'inert',
9118
+ 'is',
9119
+ 'itemid',
9120
+ 'itemprop',
9121
+ 'itemref',
9122
+ 'itemscope',
9123
+ 'itemtype',
9124
+ 'lang',
9125
+ 'nonce',
9126
+ 'part',
9127
+ 'popover',
9128
+ 'role',
9129
+ 'slot',
9130
+ 'spellcheck',
9131
+ 'style',
9132
+ 'tabindex',
9133
+ 'title',
9134
+ 'translate'
8390
9135
  ]);
8391
9136
  /**
8392
9137
  * Checks whether a `pc-script` attribute name is reserved (and so never maps to a script
@@ -8398,18 +9143,25 @@ const RESERVED_ATTRIBUTES = new Set([
8398
9143
  * @returns Whether the attribute name is reserved.
8399
9144
  */
8400
9145
  const isReservedAttribute = (name) => {
8401
- return RESERVED_ATTRIBUTES.has(name) ||
9146
+ return (RESERVED_ATTRIBUTES.has(name) ||
8402
9147
  name.startsWith('data-') ||
8403
9148
  name.startsWith('aria-') ||
8404
9149
  name.startsWith('_') ||
8405
- (name.startsWith('on') && name in HTMLElement.prototype);
9150
+ (name.startsWith('on') && name in HTMLElement.prototype));
8406
9151
  };
8407
9152
  /**
8408
9153
  * Script API members that per-property attributes must never overwrite: the engine bindings and
8409
9154
  * the (optional, so possibly undefined) lifecycle methods.
8410
9155
  */
8411
9156
  const SCRIPT_API_MEMBERS = new Set([
8412
- 'app', 'entity', 'destroy', 'initialize', 'postInitialize', 'postUpdate', 'swap', 'update'
9157
+ 'app',
9158
+ 'entity',
9159
+ 'destroy',
9160
+ 'initialize',
9161
+ 'postInitialize',
9162
+ 'postUpdate',
9163
+ 'swap',
9164
+ 'update'
8413
9165
  ]);
8414
9166
  /**
8415
9167
  * Converts a kebab-case attribute name to the camelCase script attribute name.
@@ -8425,7 +9177,7 @@ const kebabToCamel = (name) => {
8425
9177
  * @returns The kebab-case name.
8426
9178
  */
8427
9179
  const camelToKebab = (name) => {
8428
- return name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`);
9180
+ return name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
8429
9181
  };
8430
9182
  /**
8431
9183
  * Resolves an `asset:` prefix to the Asset created by the `pc-asset` element with that id.
@@ -8624,7 +9376,10 @@ class ScriptComponentElement extends ComponentElement {
8624
9376
  // Only recurse into plain objects. Class instances (Vec3, Color, Asset, Entity...)
8625
9377
  // are leaf values assigned whole, so accessor-typed script attributes receive them
8626
9378
  // through their setters instead of having a getter's returned copy mutated.
8627
- if (value && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype) {
9379
+ if (value &&
9380
+ typeof value === 'object' &&
9381
+ !Array.isArray(value) &&
9382
+ Object.getPrototypeOf(value) === Object.prototype) {
8628
9383
  if (!current || typeof current !== 'object') {
8629
9384
  target[key] = {};
8630
9385
  }
@@ -8642,7 +9397,11 @@ class ScriptComponentElement extends ComponentElement {
8642
9397
  * @returns Whether the value is a math type.
8643
9398
  */
8644
9399
  isMathType(value) {
8645
- return value instanceof playcanvas.Vec2 || value instanceof playcanvas.Vec3 || value instanceof playcanvas.Vec4 || value instanceof playcanvas.Color || value instanceof playcanvas.Quat;
9400
+ return (value instanceof playcanvas.Vec2 ||
9401
+ value instanceof playcanvas.Vec3 ||
9402
+ value instanceof playcanvas.Vec4 ||
9403
+ value instanceof playcanvas.Color ||
9404
+ value instanceof playcanvas.Quat);
8646
9405
  }
8647
9406
  /**
8648
9407
  * Converts a plain numeric array to the math type of `current`. A 3-element array targeting
@@ -8655,7 +9414,7 @@ class ScriptComponentElement extends ComponentElement {
8655
9414
  * @returns The converted value, or `null`.
8656
9415
  */
8657
9416
  arrayToMathType(current, value, key) {
8658
- if (value.every(component => typeof component === 'number' && Number.isFinite(component))) {
9417
+ if (value.every((component) => typeof component === 'number' && Number.isFinite(component))) {
8659
9418
  if (current instanceof playcanvas.Vec2 && value.length === 2)
8660
9419
  return new playcanvas.Vec2(value);
8661
9420
  if (current instanceof playcanvas.Vec3 && value.length === 3)
@@ -8927,7 +9686,10 @@ class ScriptComponentElement extends ComponentElement {
8927
9686
  mutation.removedNodes.forEach((node) => {
8928
9687
  if (node instanceof ScriptElement) {
8929
9688
  const scriptName = node.getAttribute('name');
8930
- if (scriptName && node._script && this.component && this.component.get(scriptName) === node._script) {
9689
+ if (scriptName &&
9690
+ node._script &&
9691
+ this.component &&
9692
+ this.component.get(scriptName) === node._script) {
8931
9693
  this.destroyScript(scriptName);
8932
9694
  }
8933
9695
  node._script = null;
@@ -9176,18 +9938,27 @@ class SoundSlotElement extends AsyncElement {
9176
9938
  * emit a misleading "must be a direct child" warning for what is an ordinary removal.
9177
9939
  */
9178
9940
  _soundElement = null;
9941
+ /**
9942
+ * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
9943
+ * a resume from an await abandons itself if the value has moved on, so a stale callback can
9944
+ * neither act on a torn-down tree nor add its slot alongside a re-inserted element's own
9945
+ * callback.
9946
+ */
9947
+ _connectionGeneration = 0;
9179
9948
  /**
9180
9949
  * The sound slot.
9181
9950
  */
9182
9951
  soundSlot = null;
9183
9952
  async connectedCallback() {
9953
+ const generation = ++this._connectionGeneration;
9184
9954
  const soundElement = this.soundElement;
9185
9955
  await soundElement?.ready();
9186
- // The element may have been removed, or its parent torn down, while we were waiting. A
9187
- // <pc-app> disconnects before its children, so by the time we resume the component can
9188
- // already be gone - see the matching guard in disconnectedCallback below.
9956
+ // The element may have been removed (perhaps re-inserted, which runs a callback of its
9957
+ // own), or its parent torn down, while we were waiting. A <pc-app> disconnects before
9958
+ // its children, so by the time we resume the component can already be gone - see the
9959
+ // matching guard in disconnectedCallback below.
9189
9960
  const component = soundElement?.component;
9190
- if (!this.isConnected || !component) {
9961
+ if (generation !== this._connectionGeneration || !component) {
9191
9962
  return;
9192
9963
  }
9193
9964
  const options = {
@@ -9210,12 +9981,15 @@ class SoundSlotElement extends AsyncElement {
9210
9981
  this._onReady();
9211
9982
  }
9212
9983
  disconnectedCallback() {
9984
+ // Invalidate any connectedCallback still suspended on an await
9985
+ this._connectionGeneration++;
9213
9986
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
9214
9987
  // by now. The component itself is null if the parent <pc-sound> (or the whole <pc-app>) is
9215
9988
  // being torn down — parents disconnect first and have already removed the component.
9216
9989
  this._soundElement?.component?.removeSlot(this._name);
9217
9990
  this._soundElement = null;
9218
9991
  this.soundSlot = null;
9992
+ this._resetReady();
9219
9993
  }
9220
9994
  get soundElement() {
9221
9995
  const soundElement = this.parentElement;
@@ -9611,55 +10385,170 @@ customElements.define('pc-gsplat', GSplatComponentElement);
9611
10385
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
9612
10386
  * The ModelElement interface also inherits the properties and methods of the
9613
10387
  * {@link HTMLElement} interface.
10388
+ *
10389
+ * The element becomes ready once its container asset has loaded and the instantiated hierarchy has
10390
+ * been added to the scene — `entity` is non-null by then. A failed load also settles readiness,
10391
+ * with `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen
10392
+ * for `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness
10393
+ * and instantiates anew, so a `ready()` obtained after the change resolves against the new
10394
+ * hierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never
10395
+ * becomes ready.
10396
+ *
10397
+ * @fires {Event} load - Fired each time a container asset finishes instantiating, including
10398
+ * re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a
10399
+ * capture-phase listener on an ancestor.
10400
+ * @fires {ErrorEvent} error - Fired when the container asset fails to load, with the engine's
10401
+ * error in `message`. Does not bubble. The element still becomes ready — readiness means the load
10402
+ * settled, not that it succeeded.
9614
10403
  */
9615
10404
  class ModelElement extends AsyncElement {
9616
10405
  _asset = '';
9617
10406
  _entity = null;
10407
+ /**
10408
+ * Incremented on every new load and on disconnect, and captured by a load when it starts. A
10409
+ * load that resumes from an await or a load callback abandons itself if the value has moved
10410
+ * on, so a superseded load can neither instantiate a second entity nor parent one that has
10411
+ * since been destroyed.
10412
+ */
10413
+ _loadGeneration = 0;
10414
+ /**
10415
+ * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
10416
+ * so that whatever supersedes the load can detach the handlers from the asset, rather than
10417
+ * leave them registered until the asset settles (or forever, if it never does).
10418
+ */
10419
+ _loadHandle = null;
10420
+ _errorHandle = null;
10421
+ /**
10422
+ * The root entity of the instantiated model. `null` until the container asset has loaded
10423
+ * and been instantiated, and again once the element has been removed from the document.
10424
+ * @returns The model's root entity, or `null`.
10425
+ */
10426
+ get entity() {
10427
+ return this._entity;
10428
+ }
9618
10429
  connectedCallback() {
10430
+ // A model outside an application is inert and never becomes ready, so awaiting it hangs.
10431
+ // Warn rather than fail silently, naming the parent it requires, as every other misplaced
10432
+ // element does.
10433
+ if (!this.closestApp) {
10434
+ const label = this._asset ? ` '${this._asset}'` : '';
10435
+ console.warn(`pc-model${label} must be a descendant of pc-app - model not created`);
10436
+ return;
10437
+ }
9619
10438
  this._loadModel();
9620
- this._onReady();
9621
10439
  }
9622
10440
  disconnectedCallback() {
10441
+ this._loadGeneration++;
10442
+ this._detachLoadHandlers();
9623
10443
  this._unloadModel();
10444
+ this._resetReady();
10445
+ }
10446
+ _detachLoadHandlers() {
10447
+ this._loadHandle?.off();
10448
+ this._loadHandle = null;
10449
+ this._errorHandle?.off();
10450
+ this._errorHandle = null;
10451
+ }
10452
+ /**
10453
+ * Resolves readiness and dispatches the `load` event. Called once the instantiated hierarchy
10454
+ * has been parented — readiness means "in the scene graph", matching `pc-entity`, so a ready
10455
+ * model's entity always has world transforms.
10456
+ */
10457
+ _announceLoad() {
10458
+ this._onReady();
10459
+ this.dispatchEvent(new Event('load'));
9624
10460
  }
9625
10461
  _instantiate(container) {
9626
- this._entity = container.instantiateRenderEntity();
10462
+ const generation = this._loadGeneration;
10463
+ const entity = container.instantiateRenderEntity();
10464
+ this._entity = entity;
9627
10465
  // @ts-ignore
9628
10466
  if (container.animations.length > 0) {
9629
- this._entity.addComponent('anim');
10467
+ entity.addComponent('anim');
9630
10468
  // @ts-ignore
9631
- this._entity.anim.assignAnimation('animation', container.animations[0].resource);
10469
+ entity.anim.assignAnimation('animation', container.animations[0].resource);
9632
10470
  }
10471
+ // The parent's readiness re-arms when it is torn down, so these can resume in a later
10472
+ // connection cycle. The entity is captured above and the generation re-checked, so a
10473
+ // stale resume cannot parent an entity a newer cycle has already destroyed.
9633
10474
  const parentEntityElement = this.closestEntity;
9634
10475
  if (parentEntityElement) {
9635
10476
  parentEntityElement.ready().then(() => {
9636
- parentEntityElement.entity.addChild(this._entity);
10477
+ if (generation !== this._loadGeneration) {
10478
+ return;
10479
+ }
10480
+ parentEntityElement.entity.addChild(entity);
10481
+ this._announceLoad();
9637
10482
  });
9638
10483
  }
9639
10484
  else {
9640
10485
  const appElement = this.closestApp;
9641
10486
  if (appElement) {
9642
10487
  appElement.ready().then(() => {
9643
- appElement.app.root.addChild(this._entity);
10488
+ if (generation !== this._loadGeneration) {
10489
+ return;
10490
+ }
10491
+ appElement.app.root.addChild(entity);
10492
+ this._announceLoad();
9644
10493
  });
9645
10494
  }
9646
10495
  }
9647
10496
  }
9648
10497
  async _loadModel() {
9649
10498
  this._unloadModel();
9650
- const appElement = await this.closestApp?.ready();
9651
- const app = appElement?.app;
10499
+ // Supersede any load already in flight - only the newest load may instantiate
10500
+ const generation = ++this._loadGeneration;
10501
+ this._detachLoadHandlers();
10502
+ // Re-arm readiness so a waiter obtained after an asset change resolves against the new
10503
+ // hierarchy. A no-op on first connection, where readiness is still pending.
10504
+ this._resetReady();
10505
+ const appElement = this.closestApp;
10506
+ if (!appElement) {
10507
+ // Outside pc-app; connectedCallback already warned. Reached through the asset setter.
10508
+ return;
10509
+ }
10510
+ await appElement.ready();
10511
+ // The element may have been removed, or another load started, while we waited
10512
+ if (generation !== this._loadGeneration) {
10513
+ return;
10514
+ }
10515
+ const app = appElement.app;
9652
10516
  const asset = AssetElement.get(this._asset);
9653
10517
  if (!asset) {
10518
+ // An empty id is a legitimate transient (the asset may be assigned later); a
10519
+ // non-empty one that resolves to nothing is a dead end - say so rather than staying
10520
+ // silently pending.
10521
+ if (this._asset) {
10522
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
10523
+ }
9654
10524
  return;
9655
10525
  }
9656
10526
  if (asset.loaded) {
9657
10527
  this._instantiate(asset.resource);
9658
10528
  }
9659
10529
  else {
9660
- asset.once('load', () => {
10530
+ // The generation is re-checked even though a superseded handler is detached: the
10531
+ // detach relies on how the engine's event emitter treats removal, while the check
10532
+ // holds on its own. Whichever of load/error fires first detaches the other.
10533
+ this._loadHandle = asset.once('load', () => {
10534
+ this._detachLoadHandlers();
10535
+ if (generation !== this._loadGeneration) {
10536
+ return;
10537
+ }
9661
10538
  this._instantiate(asset.resource);
9662
10539
  });
10540
+ this._errorHandle = asset.once('error', (err) => {
10541
+ this._detachLoadHandlers();
10542
+ if (generation !== this._loadGeneration) {
10543
+ return;
10544
+ }
10545
+ // A failed load settles readiness with a null entity, mirroring pc-asset:
10546
+ // readiness means the load settled, not that it succeeded.
10547
+ this.dispatchEvent(new ErrorEvent('error', {
10548
+ message: err instanceof Error ? err.message : String(err)
10549
+ }));
10550
+ this._onReady();
10551
+ });
9663
10552
  app.assets.load(asset);
9664
10553
  }
9665
10554
  }
@@ -9697,6 +10586,625 @@ class ModelElement extends AsyncElement {
9697
10586
  }
9698
10587
  customElements.define('pc-model', ModelElement);
9699
10588
 
10589
+ /**
10590
+ * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
10591
+ * resolution warnings.
10592
+ *
10593
+ * @param a - The first string.
10594
+ * @param b - The second string.
10595
+ * @returns The edit distance.
10596
+ */
10597
+ const levenshtein = (a, b) => {
10598
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
10599
+ for (let i = 1; i <= a.length; i++) {
10600
+ let previous = row[0];
10601
+ row[0] = i;
10602
+ for (let j = 1; j <= b.length; j++) {
10603
+ const current = row[j];
10604
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
10605
+ previous = current;
10606
+ }
10607
+ }
10608
+ return row[b.length];
10609
+ };
10610
+ /**
10611
+ * The NodeElement interface provides properties and methods for manipulating
10612
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-node/ | `<pc-node>`}
10613
+ * elements. The NodeElement interface also inherits the properties and methods of the
10614
+ * {@link HTMLElement} interface.
10615
+ *
10616
+ * A `pc-node` is an override element: where `pc-entity` creates an entity, `pc-node` binds to a
10617
+ * node a `pc-model` loaded and declares overrides against the authored asset — components to
10618
+ * add, properties to change, content to attach. Attributes present apply as overrides; attributes
10619
+ * absent leave authored values untouched, and removing an attribute (or assigning `null` to the
10620
+ * matching property) restores the authored value.
10621
+ *
10622
+ * `name` selects among the host model's nodes (first match in depth-first order), nesting a
10623
+ * `pc-node` inside another scopes the search to that subtree, and `index` picks among identically
10624
+ * named matches. When `name` matches more than one node and no `index` is given, the element
10625
+ * warns and binds nothing.
10626
+ *
10627
+ * The element becomes ready once bound, and never while unresolved — a missing or ambiguous
10628
+ * name warns and records the failure in `state`, readiness stays unresolved, and descendants
10629
+ * wait with it.
10630
+ *
10631
+ * The pointer events below are dispatched by the containing `<pc-app>` element when the pointer
10632
+ * intersects the bound node's geometry, exactly as for `<pc-entity>`.
10633
+ *
10634
+ * @attribute {string} name - The name of the node to bind, resolved within the nearest ancestor
10635
+ * `pc-model` (or `pc-node`) once it has instantiated.
10636
+ * @attribute {number} index - Which match to bind when `name` matches more than one node,
10637
+ * 0-based in depth-first order. Optional for a unique match; required for an ambiguous one.
10638
+ * @attribute {boolean} enabled - Overrides the node's enabled state.
10639
+ * @attribute {string} position - Overrides the node's local position, as an "x y z" triple.
10640
+ * @attribute {string} rotation - Overrides the node's local rotation (Euler angles), as an
10641
+ * "x y z" triple.
10642
+ * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
10643
+ * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
10644
+ * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
10645
+ * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
10646
+ * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
10647
+ * @attribute {string} onpointerdown - Script to run when a pointer button is pressed over the
10648
+ * node.
10649
+ * @attribute {string} onpointerup - Script to run when a pointer button is released over the
10650
+ * node.
10651
+ * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the node.
10652
+ * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the node.
10653
+ * @fires {PointerEvent} pointermove - Fired when the pointer moves over the node.
10654
+ * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the node.
10655
+ * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the node.
10656
+ */
10657
+ class NodeElement extends EntityBaseElement {
10658
+ _name = '';
10659
+ _index = null;
10660
+ _state = 'pending';
10661
+ _path = null;
10662
+ /**
10663
+ * The element whose entity roots this element's search: the nearest ancestor `pc-node`, or
10664
+ * failing that the nearest ancestor `pc-model`. Resolved on connection.
10665
+ */
10666
+ _host = null;
10667
+ /**
10668
+ * The listener following the host's binding cycles. Both host kinds announce each cycle
10669
+ * with a `ready` event — `pc-model` on every instantiation, `pc-node` on every bind.
10670
+ */
10671
+ _hostListener = null;
10672
+ /**
10673
+ * The subscription to the bound entity's destruction, detached on unbind so a retargeted
10674
+ * element cannot be reset by the eventual death of a node it no longer fronts.
10675
+ */
10676
+ _destroyHandle = null;
10677
+ /** The authored values displaced by this element's overrides, captured per property. */
10678
+ _authored = {};
10679
+ // Override values. `null` means "no override": the authored value stays in force.
10680
+ _enabled = null;
10681
+ _position = null;
10682
+ _rotation = null;
10683
+ _scale = null;
10684
+ _tags = null;
10685
+ /**
10686
+ * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
10687
+ * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
10688
+ * warns). Useful for asserting a document's bindings programmatically.
10689
+ * @returns The binding state.
10690
+ */
10691
+ get state() {
10692
+ return this._state;
10693
+ }
10694
+ /**
10695
+ * The path of the bound node below the search root, `/`-separated, or `null` while not
10696
+ * bound.
10697
+ * @returns The bound node's path, or `null`.
10698
+ */
10699
+ get path() {
10700
+ return this._path;
10701
+ }
10702
+ connectedCallback() {
10703
+ const host = (this.parentElement?.closest('pc-model, pc-node') ?? null);
10704
+ if (!host) {
10705
+ const label = this._name ? ` '${this._name}'` : '';
10706
+ console.warn(`pc-node${label} must be a descendant of pc-model - node not bound`);
10707
+ return;
10708
+ }
10709
+ this._host = host;
10710
+ // Follow the host's binding cycles. `ready` bubbles, so cycles of elements nested under
10711
+ // the host pass through it - only the host's own count.
10712
+ this._hostListener = (event) => {
10713
+ if (event.target !== this._host) {
10714
+ return;
10715
+ }
10716
+ this._rebind();
10717
+ };
10718
+ host.addEventListener('ready', this._hostListener);
10719
+ // The host may already be instantiated (an element inserted after load binds immediately)
10720
+ this._rebind();
10721
+ }
10722
+ disconnectedCallback() {
10723
+ if (this._host && this._hostListener) {
10724
+ this._host.removeEventListener('ready', this._hostListener);
10725
+ }
10726
+ this._host = null;
10727
+ this._hostListener = null;
10728
+ // Removal reverts: the model owns the node, so the entity is left as authored. Children
10729
+ // clean up through their own disconnect behavior.
10730
+ this._unbind();
10731
+ this._state = 'pending';
10732
+ }
10733
+ /**
10734
+ * Re-resolves the binding against the host's current hierarchy: on connection, on a `name`
10735
+ * or `index` change, and on every host cycle (a model [re]instantiating, an enclosing
10736
+ * `pc-node` [re]binding). When re-resolution yields the entity already bound, the binding
10737
+ * is retained untouched — a redundant edit must not flicker overrides through a revert.
10738
+ */
10739
+ _rebind() {
10740
+ const hostEntity = this._host?.entity ?? null;
10741
+ if (!hostEntity || !this._name) {
10742
+ // Host not instantiated (or nothing to look up yet): return to pending. An assigned
10743
+ // name arriving later, or the host's next cycle, resolves it.
10744
+ this._unbind();
10745
+ this._state = 'pending';
10746
+ return;
10747
+ }
10748
+ const target = this._resolve(hostEntity);
10749
+ if (target && target === this._entity) {
10750
+ this._path = this._pathOf(target, hostEntity);
10751
+ return;
10752
+ }
10753
+ this._unbind();
10754
+ if (!target) {
10755
+ // _resolve warned and set the failure state
10756
+ return;
10757
+ }
10758
+ this._bind(target, hostEntity);
10759
+ }
10760
+ /**
10761
+ * Resolves `name` (and `index`) to an entity under `hostEntity`, warning and recording the
10762
+ * failure state when it cannot.
10763
+ *
10764
+ * @param hostEntity - The root of the search.
10765
+ * @returns The resolved entity, or `null`.
10766
+ */
10767
+ _resolve(hostEntity) {
10768
+ const matches = hostEntity.find((node) => node.name === this._name);
10769
+ if (matches.length === 0) {
10770
+ const closest = this._closestName(hostEntity);
10771
+ const hint = closest ? ` - closest match: '${closest}'` : '';
10772
+ console.warn(`pc-node '${this._name}' not found in ${this._describeHost()}${hint}`);
10773
+ this._state = 'missing';
10774
+ return null;
10775
+ }
10776
+ let target;
10777
+ if (this._index !== null) {
10778
+ if (this._index >= matches.length) {
10779
+ console.warn(`pc-node '${this._name}' index ${this._index} is out of range - ${matches.length} match(es) in ${this._describeHost()}`);
10780
+ this._state = 'missing';
10781
+ return null;
10782
+ }
10783
+ target = matches[this._index];
10784
+ }
10785
+ else if (matches.length > 1) {
10786
+ // Ambiguity binds nothing: a fallback guess performs side effects on the wrong
10787
+ // scene node, and would go wrong silently when a re-export introduces a duplicate
10788
+ // name. The candidates tell the author exactly what to write.
10789
+ const candidates = matches.map((m, i) => `[${i}] ${this._pathOf(m, hostEntity)}`).join(', ');
10790
+ console.warn(`pc-node '${this._name}' is ambiguous in ${this._describeHost()} - specify index: ${candidates}`);
10791
+ this._state = 'ambiguous';
10792
+ return null;
10793
+ }
10794
+ else {
10795
+ target = matches[0];
10796
+ }
10797
+ const owner = this.closestApp?.elementFromEntity(target);
10798
+ if (owner && owner !== this) {
10799
+ console.warn(`pc-node '${this._name}' resolves to a node already bound by another element - element ignored`);
10800
+ this._state = 'duplicate';
10801
+ return null;
10802
+ }
10803
+ return target;
10804
+ }
10805
+ /**
10806
+ * Binds `target`: registers it (making it a pick target), hooks its destruction, applies
10807
+ * this element's overrides, announces readiness and builds the deferred child subtree.
10808
+ *
10809
+ * @param target - The entity to bind.
10810
+ * @param hostEntity - The search root, for the path.
10811
+ */
10812
+ _bind(target, hostEntity) {
10813
+ this._entity = target;
10814
+ this._registerEntity(target);
10815
+ this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
10816
+ this._state = 'bound';
10817
+ this._path = this._pathOf(target, hostEntity);
10818
+ this._applyOverrides();
10819
+ this._onReady();
10820
+ this._buildChildren();
10821
+ }
10822
+ /**
10823
+ * Dissolves the current binding, restoring every authored value this element's overrides
10824
+ * displaced and removing the decorations this binding hosts: attachment entities are
10825
+ * destroyed (re-created against the next binding) and component decorations are removed
10826
+ * from the abandoned node. Both sweeps are scoped by `closestEntity`, so a still-bound
10827
+ * nested `pc-node` keeps its own decorations. Safe to call in any state.
10828
+ */
10829
+ _unbind() {
10830
+ const entity = this._entity;
10831
+ if (!entity) {
10832
+ return;
10833
+ }
10834
+ this._revertOverrides();
10835
+ // Attachment points anchor to the bound node, so they cannot outlive the binding. Each
10836
+ // destroyed entity resets its element, which the next _buildChildren re-creates.
10837
+ this.querySelectorAll('pc-entity').forEach((child) => {
10838
+ if (child.closestEntity === this) {
10839
+ child.entity?.destroy();
10840
+ }
10841
+ });
10842
+ this._destroyHandle?.off();
10843
+ this._destroyHandle = null;
10844
+ this._unregisterEntity(entity);
10845
+ this._entity = null;
10846
+ this._path = null;
10847
+ this._authored = {};
10848
+ // Component decorations come off through the same hook the host-ready cycle uses. A
10849
+ // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
10850
+ // `_entity` is cleared, so the hook sees a host without an entity.
10851
+ this.querySelectorAll('*').forEach((child) => {
10852
+ if (child instanceof ComponentElement && child.closestEntity === this) {
10853
+ child._hostCycled();
10854
+ }
10855
+ });
10856
+ this._resetReady();
10857
+ }
10858
+ /**
10859
+ * Handles the destruction of the bound entity - its model unloading, reloading, or a script
10860
+ * destroying it. There is nothing to revert on a destroyed entity; the element returns to
10861
+ * pending and the host's next cycle re-resolves it.
10862
+ */
10863
+ _onEntityDestroy(entity) {
10864
+ this._destroyHandle = null;
10865
+ this._unregisterEntity(entity);
10866
+ this._entity = null;
10867
+ this._path = null;
10868
+ this._authored = {};
10869
+ this._state = 'pending';
10870
+ this._resetReady();
10871
+ }
10872
+ /**
10873
+ * Creates and parents the entities of child `pc-entity` elements - the attachment points.
10874
+ * Mirrors the runtime-insertion path in EntityElement.connectedCallback: children were
10875
+ * deferred while this host was unresolved (or reset when a previous binding dissolved), and
10876
+ * build here once it binds.
10877
+ */
10878
+ _buildChildren() {
10879
+ const app = this.closestApp?.app;
10880
+ if (!app) {
10881
+ return;
10882
+ }
10883
+ const childEntities = this.querySelectorAll('pc-entity');
10884
+ childEntities.forEach((child) => {
10885
+ child._createEntity(app);
10886
+ });
10887
+ childEntities.forEach((child) => {
10888
+ child._buildHierarchy(app);
10889
+ });
10890
+ }
10891
+ /**
10892
+ * Applies every override that is explicitly set, capturing the authored value it displaces.
10893
+ */
10894
+ _applyOverrides() {
10895
+ if (this._enabled !== null) {
10896
+ this.enabled = this._enabled;
10897
+ }
10898
+ if (this._position !== null) {
10899
+ this.position = this._position;
10900
+ }
10901
+ if (this._rotation !== null) {
10902
+ this.rotation = this._rotation;
10903
+ }
10904
+ if (this._scale !== null) {
10905
+ this.scale = this._scale;
10906
+ }
10907
+ if (this._tags !== null) {
10908
+ this.tags = this._tags;
10909
+ }
10910
+ }
10911
+ /**
10912
+ * Restores every authored value this element's overrides displaced. The override values
10913
+ * themselves are kept - they re-apply on the next binding.
10914
+ */
10915
+ _revertOverrides() {
10916
+ const entity = this._entity;
10917
+ const authored = this._authored;
10918
+ if (authored.enabled !== undefined) {
10919
+ entity.enabled = authored.enabled;
10920
+ }
10921
+ if (authored.position) {
10922
+ entity.setLocalPosition(authored.position);
10923
+ }
10924
+ if (authored.rotation) {
10925
+ entity.setLocalRotation(authored.rotation);
10926
+ }
10927
+ if (authored.scale) {
10928
+ entity.setLocalScale(authored.scale);
10929
+ }
10930
+ if (authored.tags) {
10931
+ entity.tags.clear();
10932
+ entity.tags.add(authored.tags);
10933
+ }
10934
+ this._authored = {};
10935
+ }
10936
+ /**
10937
+ * Renders the path of `node` below `root`, for the `path` property and the resolution
10938
+ * warnings.
10939
+ *
10940
+ * @param node - The node to describe.
10941
+ * @param root - The search root.
10942
+ * @returns The `/`-separated path.
10943
+ */
10944
+ _pathOf(node, root) {
10945
+ const parts = [];
10946
+ for (let current = node; current && current !== root; current = current.parent) {
10947
+ parts.unshift(current.name);
10948
+ }
10949
+ return parts.join('/') || node.name;
10950
+ }
10951
+ /**
10952
+ * Describes the search root for warnings: the model's asset id, or the enclosing node's
10953
+ * name.
10954
+ * @returns The description.
10955
+ */
10956
+ _describeHost() {
10957
+ if (this._host instanceof ModelElement) {
10958
+ return `model '${this._host.asset}'`;
10959
+ }
10960
+ return `pc-node '${this._host?.name ?? ''}' subtree`;
10961
+ }
10962
+ /**
10963
+ * Finds the node name nearest to the missing `name`, for the miss warning. The names are
10964
+ * already in hand from resolution, so the suggestion is nearly free.
10965
+ *
10966
+ * @param hostEntity - The root of the search.
10967
+ * @returns The closest name within an edit distance of 2, or `null`.
10968
+ */
10969
+ _closestName(hostEntity) {
10970
+ let best = null;
10971
+ let bestDistance = 3;
10972
+ hostEntity.find((node) => {
10973
+ const distance = levenshtein(this._name, node.name);
10974
+ if (distance < bestDistance) {
10975
+ bestDistance = distance;
10976
+ best = node.name;
10977
+ }
10978
+ return false;
10979
+ });
10980
+ return best;
10981
+ }
10982
+ /**
10983
+ * Sets the name of the node to bind. A change retargets: the current binding's overrides
10984
+ * revert and the new name resolves afresh. `name` on a `pc-node` is never a rename of the
10985
+ * authored node - it is only ever a reference.
10986
+ * @param value - The node name.
10987
+ */
10988
+ set name(value) {
10989
+ this._name = value;
10990
+ if (this.isConnected && this._host) {
10991
+ this._rebind();
10992
+ }
10993
+ }
10994
+ /**
10995
+ * Gets the name of the node to bind.
10996
+ * @returns The node name.
10997
+ */
10998
+ get name() {
10999
+ return this._name;
11000
+ }
11001
+ /**
11002
+ * Sets which match to bind when `name` matches more than one node, 0-based in depth-first
11003
+ * order. A change retargets, like `name`. `null` means unset - required when the name is
11004
+ * ambiguous, optional otherwise.
11005
+ * @param value - The match index, or `null`.
11006
+ */
11007
+ set index(value) {
11008
+ this._index = value;
11009
+ if (this.isConnected && this._host) {
11010
+ this._rebind();
11011
+ }
11012
+ }
11013
+ /**
11014
+ * Gets which match to bind.
11015
+ * @returns The match index, or `null` when unset.
11016
+ */
11017
+ get index() {
11018
+ return this._index;
11019
+ }
11020
+ /**
11021
+ * Sets the enabled override. `null` clears it, restoring the authored state.
11022
+ * @param value - The enabled state, or `null`.
11023
+ */
11024
+ set enabled(value) {
11025
+ this._enabled = value;
11026
+ const entity = this._state === 'bound' ? this._entity : null;
11027
+ if (!entity) {
11028
+ return;
11029
+ }
11030
+ if (value !== null) {
11031
+ this._authored.enabled ??= entity.enabled;
11032
+ entity.enabled = value;
11033
+ }
11034
+ else if (this._authored.enabled !== undefined) {
11035
+ entity.enabled = this._authored.enabled;
11036
+ delete this._authored.enabled;
11037
+ }
11038
+ }
11039
+ /**
11040
+ * Gets the enabled override.
11041
+ * @returns The enabled state, or `null` while no override is set.
11042
+ */
11043
+ get enabled() {
11044
+ return this._enabled;
11045
+ }
11046
+ /**
11047
+ * Sets the local position override. `null` clears it, restoring the authored position.
11048
+ * @param value - The position, or `null`.
11049
+ */
11050
+ set position(value) {
11051
+ this._position = value;
11052
+ const entity = this._state === 'bound' ? this._entity : null;
11053
+ if (!entity) {
11054
+ return;
11055
+ }
11056
+ if (value !== null) {
11057
+ this._authored.position ??= entity.getLocalPosition().clone();
11058
+ entity.setLocalPosition(value);
11059
+ }
11060
+ else if (this._authored.position) {
11061
+ entity.setLocalPosition(this._authored.position);
11062
+ delete this._authored.position;
11063
+ }
11064
+ }
11065
+ /**
11066
+ * Gets the local position override.
11067
+ * @returns The position, or `null` while no override is set.
11068
+ */
11069
+ get position() {
11070
+ return this._position;
11071
+ }
11072
+ /**
11073
+ * Sets the local rotation override, as Euler angles in degrees. `null` clears it, restoring
11074
+ * the authored rotation.
11075
+ * @param value - The rotation, or `null`.
11076
+ */
11077
+ set rotation(value) {
11078
+ this._rotation = value;
11079
+ const entity = this._state === 'bound' ? this._entity : null;
11080
+ if (!entity) {
11081
+ return;
11082
+ }
11083
+ if (value !== null) {
11084
+ // The authored rotation is cached as a quaternion: it restores exactly, where a
11085
+ // round trip through Euler angles need not.
11086
+ this._authored.rotation ??= entity.getLocalRotation().clone();
11087
+ entity.setLocalEulerAngles(value);
11088
+ }
11089
+ else if (this._authored.rotation) {
11090
+ entity.setLocalRotation(this._authored.rotation);
11091
+ delete this._authored.rotation;
11092
+ }
11093
+ }
11094
+ /**
11095
+ * Gets the local rotation override.
11096
+ * @returns The rotation, or `null` while no override is set.
11097
+ */
11098
+ get rotation() {
11099
+ return this._rotation;
11100
+ }
11101
+ /**
11102
+ * Sets the local scale override. `null` clears it, restoring the authored scale.
11103
+ * @param value - The scale, or `null`.
11104
+ */
11105
+ set scale(value) {
11106
+ this._scale = value;
11107
+ const entity = this._state === 'bound' ? this._entity : null;
11108
+ if (!entity) {
11109
+ return;
11110
+ }
11111
+ if (value !== null) {
11112
+ this._authored.scale ??= entity.getLocalScale().clone();
11113
+ entity.setLocalScale(value);
11114
+ }
11115
+ else if (this._authored.scale) {
11116
+ entity.setLocalScale(this._authored.scale);
11117
+ delete this._authored.scale;
11118
+ }
11119
+ }
11120
+ /**
11121
+ * Gets the local scale override.
11122
+ * @returns The scale, or `null` while no override is set.
11123
+ */
11124
+ get scale() {
11125
+ return this._scale;
11126
+ }
11127
+ /**
11128
+ * Sets the tags override. `null` clears it, restoring the authored tags.
11129
+ * @param value - The tags, or `null`.
11130
+ */
11131
+ set tags(value) {
11132
+ this._tags = value;
11133
+ const entity = this._state === 'bound' ? this._entity : null;
11134
+ if (!entity) {
11135
+ return;
11136
+ }
11137
+ if (value !== null) {
11138
+ this._authored.tags ??= entity.tags.list().slice();
11139
+ entity.tags.clear();
11140
+ entity.tags.add(value);
11141
+ }
11142
+ else if (this._authored.tags) {
11143
+ entity.tags.clear();
11144
+ entity.tags.add(this._authored.tags);
11145
+ delete this._authored.tags;
11146
+ }
11147
+ }
11148
+ /**
11149
+ * Gets the tags override.
11150
+ * @returns The tags, or `null` while no override is set.
11151
+ */
11152
+ get tags() {
11153
+ return this._tags;
11154
+ }
11155
+ static get observedAttributes() {
11156
+ return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
11157
+ }
11158
+ attributeChangedCallback(name, _oldValue, newValue) {
11159
+ switch (name) {
11160
+ case 'enabled':
11161
+ this.enabled = newValue === null ? null : parseBool(newValue, true);
11162
+ break;
11163
+ case 'index':
11164
+ if (newValue === null) {
11165
+ this.index = null;
11166
+ }
11167
+ else {
11168
+ // Number('') is 0, which would make index="" silently mean the first match
11169
+ const index = newValue.trim() === '' ? NaN : Number(newValue);
11170
+ if (!Number.isInteger(index) || index < 0) {
11171
+ // Invalid values are treated as absent: under ambiguity that means
11172
+ // unbound, the fail-safe direction.
11173
+ console.warn(`pc-node index '${newValue}' is not a non-negative integer - treated as absent`);
11174
+ this.index = null;
11175
+ }
11176
+ else {
11177
+ this.index = index;
11178
+ }
11179
+ }
11180
+ break;
11181
+ case 'name':
11182
+ this.name = newValue ?? '';
11183
+ break;
11184
+ case 'position':
11185
+ this.position = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ZERO, name);
11186
+ break;
11187
+ case 'rotation':
11188
+ this.rotation = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ZERO, name);
11189
+ break;
11190
+ case 'scale':
11191
+ this.scale = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ONE, name);
11192
+ break;
11193
+ case 'tags':
11194
+ this.tags = newValue === null ? null : parseTags(newValue);
11195
+ break;
11196
+ case 'onpointerenter':
11197
+ case 'onpointerleave':
11198
+ case 'onpointerdown':
11199
+ case 'onpointerup':
11200
+ case 'onpointermove':
11201
+ this._updateInlineHandler(name, newValue);
11202
+ break;
11203
+ }
11204
+ }
11205
+ }
11206
+ customElements.define('pc-node', NodeElement);
11207
+
9700
11208
  /**
9701
11209
  * The SceneElement interface provides properties and methods for manipulating
9702
11210
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-scene/ | `<pc-scene>`} elements.
@@ -9759,10 +11267,17 @@ class SceneElement extends AsyncElement {
9759
11267
  return;
9760
11268
  }
9761
11269
  this._scene = app.scene;
9762
- this.updateSceneSettings();
11270
+ this._updateSceneSettings();
9763
11271
  this._onReady();
9764
11272
  }
9765
- updateSceneSettings() {
11273
+ disconnectedCallback() {
11274
+ // The scene belongs to the application, and removing this element - or the <pc-app>
11275
+ // above it, which disconnects first - parts the two. Re-arm readiness so a re-inserted
11276
+ // element announces the scene it acquires then, not the one it lost here.
11277
+ this._scene = null;
11278
+ this._resetReady();
11279
+ }
11280
+ _updateSceneSettings() {
9766
11281
  if (this._scene) {
9767
11282
  this._scene.fog.type = this._fog;
9768
11283
  this._scene.fog.color = this._fogColor;
@@ -9930,24 +11445,49 @@ class SkyElement extends AsyncElement {
9930
11445
  _type = 'infinite';
9931
11446
  _scene = null;
9932
11447
  _appElement = null;
11448
+ /**
11449
+ * Incremented on every new load and on disconnect, and captured by a load when it starts. A
11450
+ * load that resumes from an await or a load callback abandons itself if the value has moved
11451
+ * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
11452
+ */
11453
+ _loadGeneration = 0;
11454
+ /**
11455
+ * The pending asset-load subscription of the current load, if it is waiting for its asset.
11456
+ * Held so that whatever supersedes the load can detach the handler from the asset, rather
11457
+ * than leave it registered until the asset loads (or forever, if it never does).
11458
+ */
11459
+ _loadHandle = null;
9933
11460
  connectedCallback() {
9934
11461
  this._loadSkybox();
9935
11462
  this._onReady();
9936
11463
  }
9937
11464
  disconnectedCallback() {
11465
+ this._loadGeneration++;
11466
+ this._detachLoadHandler();
9938
11467
  this._unloadSkybox();
9939
11468
  this._appElement = null;
11469
+ this._resetReady();
11470
+ }
11471
+ _detachLoadHandler() {
11472
+ this._loadHandle?.off();
11473
+ this._loadHandle = null;
9940
11474
  }
9941
11475
  _generateSkybox(asset) {
9942
11476
  if (!this._scene)
9943
11477
  return;
9944
11478
  const source = asset.resource;
9945
11479
  const skybox = playcanvas.EnvLighting.generateSkyboxCubemap(source);
9946
- skybox.anisotropy = 4;
11480
+ // This element owns what it generated (see _unloadSkybox) - replacing a skybox from an
11481
+ // earlier load must release it, not orphan it on the GPU
11482
+ this._scene.skybox?.destroy();
9947
11483
  this._scene.skybox = skybox;
9948
11484
  if (this._lighting) {
9949
11485
  const lighting = playcanvas.EnvLighting.generateLightingSource(source);
9950
11486
  const envAtlas = playcanvas.EnvLighting.generateAtlas(lighting);
11487
+ // The lighting source is an intermediate: the atlas is rendered from it and it is
11488
+ // not needed afterwards
11489
+ lighting.destroy();
11490
+ this._scene.envAtlas?.destroy();
9951
11491
  this._scene.envAtlas = envAtlas;
9952
11492
  }
9953
11493
  const layer = this._scene.layers.getLayerById(playcanvas.LAYERID_SKYBOX);
@@ -9961,7 +11501,14 @@ class SkyElement extends AsyncElement {
9961
11501
  this._scene.skyboxMip = this._mipLevel;
9962
11502
  }
9963
11503
  async _loadSkybox() {
11504
+ // Supersede any load already in flight - only the newest load may generate the skybox
11505
+ const generation = ++this._loadGeneration;
11506
+ this._detachLoadHandler();
9964
11507
  const appElement = await this.closestApp?.ready();
11508
+ // The element may have been removed, or another load started, while we waited
11509
+ if (generation !== this._loadGeneration) {
11510
+ return;
11511
+ }
9965
11512
  const app = appElement?.app;
9966
11513
  if (!appElement || !app) {
9967
11514
  return;
@@ -9976,7 +11523,14 @@ class SkyElement extends AsyncElement {
9976
11523
  this._generateSkybox(asset);
9977
11524
  }
9978
11525
  else {
9979
- asset.once('load', () => {
11526
+ // The generation is re-checked even though a superseded handler is detached: the
11527
+ // detach relies on how the engine's event emitter treats removal, while the check
11528
+ // holds on its own.
11529
+ this._loadHandle = asset.once('load', () => {
11530
+ this._loadHandle = null;
11531
+ if (generation !== this._loadGeneration) {
11532
+ return;
11533
+ }
9980
11534
  this._generateSkybox(asset);
9981
11535
  });
9982
11536
  app.assets.load(asset);
@@ -10179,6 +11733,7 @@ exports.CameraComponentElement = CameraComponentElement;
10179
11733
  exports.CollisionComponentElement = CollisionComponentElement;
10180
11734
  exports.ComponentElement = ComponentElement;
10181
11735
  exports.ElementComponentElement = ElementComponentElement;
11736
+ exports.EntityBaseElement = EntityBaseElement;
10182
11737
  exports.EntityElement = EntityElement;
10183
11738
  exports.GSplatComponentElement = GSplatComponentElement;
10184
11739
  exports.LayoutChildComponentElement = LayoutChildComponentElement;
@@ -10188,6 +11743,7 @@ exports.ListenerComponentElement = ListenerComponentElement;
10188
11743
  exports.MaterialElement = MaterialElement;
10189
11744
  exports.ModelElement = ModelElement;
10190
11745
  exports.ModuleElement = ModuleElement;
11746
+ exports.NodeElement = NodeElement;
10191
11747
  exports.ParticleSystemComponentElement = ParticleSystemComponentElement;
10192
11748
  exports.RenderComponentElement = RenderComponentElement;
10193
11749
  exports.RigidBodyComponentElement = RigidBodyComponentElement;