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