@playcanvas/web-components 0.11.1 → 0.13.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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.cts +246 -0
  3. package/dist/app.d.ts +38 -13
  4. package/dist/asset.d.cts +218 -0
  5. package/dist/asset.d.ts +145 -6
  6. package/dist/async-element.d.cts +103 -0
  7. package/dist/async-element.d.ts +11 -8
  8. package/dist/colors.d.cts +1 -0
  9. package/dist/components/button-component.d.cts +186 -0
  10. package/dist/components/button-component.d.ts +1 -1
  11. package/dist/components/camera-component.d.cts +253 -0
  12. package/dist/components/camera-component.d.ts +1 -1
  13. package/dist/components/collision-component.d.cts +73 -0
  14. package/dist/components/collision-component.d.ts +17 -1
  15. package/dist/components/component.d.cts +82 -0
  16. package/dist/components/component.d.ts +20 -1
  17. package/dist/components/element-component.d.cts +316 -0
  18. package/dist/components/element-component.d.ts +1 -1
  19. package/dist/components/gsplat-component.d.cts +108 -0
  20. package/dist/components/gsplat-component.d.ts +1 -1
  21. package/dist/components/layoutchild-component.d.cts +110 -0
  22. package/dist/components/layoutchild-component.d.ts +1 -1
  23. package/dist/components/layoutgroup-component.d.cts +136 -0
  24. package/dist/components/layoutgroup-component.d.ts +1 -1
  25. package/dist/components/light-component.d.cts +264 -0
  26. package/dist/components/light-component.d.ts +1 -1
  27. package/dist/components/listener-component.d.cts +20 -0
  28. package/dist/components/listener-component.d.ts +1 -1
  29. package/dist/components/particlesystem-component.d.cts +52 -0
  30. package/dist/components/particlesystem-component.d.ts +1 -1
  31. package/dist/components/render-component.d.cts +76 -0
  32. package/dist/components/render-component.d.ts +1 -1
  33. package/dist/components/rigidbody-component.d.cts +88 -0
  34. package/dist/components/rigidbody-component.d.ts +1 -1
  35. package/dist/components/screen-component.d.cts +70 -0
  36. package/dist/components/screen-component.d.ts +1 -1
  37. package/dist/components/script-component.d.cts +163 -0
  38. package/dist/components/script-component.d.ts +1 -1
  39. package/dist/components/script.d.cts +94 -0
  40. package/dist/components/script.d.ts +1 -1
  41. package/dist/components/scrollbar-component.d.cts +69 -0
  42. package/dist/components/scrollbar-component.d.ts +1 -1
  43. package/dist/components/scrollview-component.d.cts +178 -0
  44. package/dist/components/scrollview-component.d.ts +1 -1
  45. package/dist/components/sound-component.d.cts +108 -0
  46. package/dist/components/sound-component.d.ts +1 -1
  47. package/dist/components/sound-slot.d.cts +134 -0
  48. package/dist/components/sound-slot.d.ts +2 -2
  49. package/dist/custom-elements.json +1127 -179
  50. package/dist/entity-base.d.cts +67 -0
  51. package/dist/entity-base.d.ts +67 -0
  52. package/dist/entity.d.cts +131 -0
  53. package/dist/entity.d.ts +3 -38
  54. package/dist/index.d.cts +79 -0
  55. package/dist/index.d.ts +35 -32
  56. package/dist/loading-bar.d.cts +35 -0
  57. package/dist/material.d.cts +1011 -0
  58. package/dist/material.d.ts +2 -1
  59. package/dist/model.d.cts +72 -0
  60. package/dist/model.d.ts +27 -5
  61. package/dist/module.d.cts +29 -0
  62. package/dist/module.d.ts +16 -10
  63. package/dist/node.d.cts +253 -0
  64. package/dist/node.d.ts +253 -0
  65. package/dist/parse.d.cts +147 -0
  66. package/dist/pwc.cjs +1496 -200
  67. package/dist/pwc.cjs.map +1 -1
  68. package/dist/pwc.js +1497 -201
  69. package/dist/pwc.js.map +1 -1
  70. package/dist/pwc.min.js +1 -1
  71. package/dist/pwc.min.js.map +1 -1
  72. package/dist/pwc.min.mjs +1 -1
  73. package/dist/pwc.min.mjs.map +1 -1
  74. package/dist/pwc.mjs +1496 -202
  75. package/dist/pwc.mjs.map +1 -1
  76. package/dist/scene.d.cts +117 -0
  77. package/dist/scene.d.ts +1 -1
  78. package/dist/sky.d.cts +121 -0
  79. package/dist/sky.d.ts +1 -1
  80. package/dist/vscode.html-custom-data.json +127 -5
  81. package/dist/web-types.json +399 -59
  82. package/package.json +16 -7
  83. package/src/app.ts +137 -45
  84. package/src/asset.ts +439 -9
  85. package/src/async-element.ts +11 -8
  86. package/src/components/collision-component.ts +35 -0
  87. package/src/components/component.ts +93 -3
  88. package/src/entity-base.ts +136 -0
  89. package/src/entity.ts +23 -117
  90. package/src/index.ts +5 -0
  91. package/src/loading-bar.ts +2 -2
  92. package/src/material.ts +2 -2
  93. package/src/model.ts +79 -11
  94. package/src/module.ts +39 -20
  95. package/src/node.ts +715 -0
  96. package/src/sky.ts +0 -1
package/dist/pwc.cjs CHANGED
@@ -29,12 +29,13 @@ class AsyncElement extends HTMLElement {
29
29
  return this.parentElement?.closest('pc-app') ?? null;
30
30
  }
31
31
  /**
32
- * The nearest ancestor `<pc-entity>` element, or `null` if this element has no `<pc-entity>`
33
- * ancestor. The search starts at the parent, so an element never resolves to itself.
34
- * @returns The closest entity element, or `null`.
32
+ * The nearest ancestor element that fronts an entity `<pc-entity>` or `<pc-node>` — or
33
+ * `null` if this element has no such ancestor. The search starts at the parent, so an element
34
+ * never resolves to itself.
35
+ * @returns The closest entity-fronting element, or `null`.
35
36
  */
36
37
  get closestEntity() {
37
- return this.parentElement?.closest('pc-entity') ?? null;
38
+ return this.parentElement?.closest('pc-entity, pc-node') ?? null;
38
39
  }
39
40
  /**
40
41
  * Called when the element is fully initialized and ready. Subclasses should call this when
@@ -109,12 +110,18 @@ async function whenReady(target) {
109
110
 
110
111
  /**
111
112
  * The ModuleElement interface provides properties and methods for manipulating
112
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-module/ | `<pc-module>`} elements.
113
- * The ModuleElement interface also inherits the properties and methods of the
114
- * {@link HTMLElement} interface.
113
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-module/ | `<pc-module>`}
114
+ * elements. The ModuleElement interface also inherits the properties and methods of the
115
+ * {@link AsyncElement} interface.
115
116
  *
116
- * Note that these attributes are read once when the element is created, so changing them later
117
- * has no effect.
117
+ * The attributes are read once, when the module starts loading - on the element's first
118
+ * connection, or earlier if a containing `<pc-app>` boots first and collects it - so changing
119
+ * them later has no effect. The element becomes ready once the module has loaded. WebAssembly
120
+ * modules configure engine-global state that never unloads, so readiness is not re-armed by
121
+ * removing the element, and a re-inserted element does not load again.
122
+ *
123
+ * A `<pc-module>` without a `name` warns and never becomes ready; a containing `<pc-app>` still
124
+ * boots.
118
125
  *
119
126
  * @attribute {string} name - The name of the WebAssembly module to configure, e.g. `Basis` or
120
127
  * `Ammo`.
@@ -123,19 +130,22 @@ async function whenReady(target) {
123
130
  * @attribute {string} fallback - The URL of the module's asm.js fallback script, used when
124
131
  * WebAssembly is unavailable.
125
132
  */
126
- class ModuleElement extends HTMLElement {
127
- loadPromise;
128
- /** @ignore */
129
- constructor() {
130
- super();
131
- this.loadPromise = this.loadModule();
133
+ class ModuleElement extends AsyncElement {
134
+ _loadPromise = null;
135
+ connectedCallback() {
136
+ this._getLoadPromise();
132
137
  }
133
- async loadModule() {
138
+ async _loadModule() {
134
139
  const name = this.getAttribute('name');
135
- const glueUrl = this.getAttribute('glue');
136
- const wasmUrl = this.getAttribute('wasm');
137
- const fallbackUrl = this.getAttribute('fallback');
138
- const config = { glueUrl, wasmUrl, fallbackUrl };
140
+ if (!name) {
141
+ console.warn("pc-module requires a 'name' attribute - no module was configured");
142
+ return;
143
+ }
144
+ const config = {
145
+ glueUrl: this.getAttribute('glue') ?? undefined,
146
+ wasmUrl: this.getAttribute('wasm') ?? undefined,
147
+ fallbackUrl: this.getAttribute('fallback') ?? undefined
148
+ };
139
149
  if (name === 'Basis') {
140
150
  playcanvas.basisInitialize(config);
141
151
  }
@@ -145,16 +155,22 @@ class ModuleElement extends HTMLElement {
145
155
  playcanvas.WasmModule.getInstance(name, () => resolve());
146
156
  });
147
157
  }
158
+ this._onReady();
148
159
  }
149
160
  /**
150
- * Returns the promise that settles when the module has loaded. Awaited by the containing
151
- * `<pc-app>` element before it creates its graphics device.
161
+ * Returns the promise that settles when the module has loaded, starting the load if it has
162
+ * not already started - a containing `<pc-app>` boots in document order, so it may collect
163
+ * this element before the element's own connectedCallback has run. A missing `name` resolves
164
+ * the promise without configuring anything, so a misconfigured module never blocks the app.
152
165
  *
153
166
  * @returns The load promise.
154
167
  * @internal
155
168
  */
156
169
  _getLoadPromise() {
157
- return this.loadPromise;
170
+ if (!this._loadPromise) {
171
+ this._loadPromise = this._loadModule();
172
+ }
173
+ return this._loadPromise;
158
174
  }
159
175
  }
160
176
  customElements.define('pc-module', ModuleElement);
@@ -184,9 +200,9 @@ class LoadingBar {
184
200
  this._track.setAttribute('aria-label', 'Loading');
185
201
  this._track.setAttribute('aria-valuemin', '0');
186
202
  this._track.setAttribute('aria-valuemax', '100');
187
- // Fixed positioning matches the canvas, which always fills the window (FILLMODE_FILL_WINDOW)
203
+ // Anchored to the pc-app element, which the library's base styles make a positioned box
188
204
  this._track.style.cssText = [
189
- 'position: fixed',
205
+ 'position: absolute',
190
206
  'top: 0',
191
207
  'left: 0',
192
208
  'width: 100%',
@@ -698,16 +714,44 @@ const getEntity = (ref) => {
698
714
 
699
715
  /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
700
716
  const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'];
717
+ /**
718
+ * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
719
+ * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
720
+ * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
721
+ * so any page rule - however plain - overrides these defaults.
722
+ */
723
+ const ensureBaseStyles = () => {
724
+ const id = 'pc-app-styles';
725
+ if (document.getElementById(id)) {
726
+ return;
727
+ }
728
+ const style = document.createElement('style');
729
+ style.id = id;
730
+ style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
731
+ document.head.appendChild(style);
732
+ };
701
733
  /**
702
734
  * The AppElement interface provides properties and methods for manipulating
703
735
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
704
736
  * The AppElement interface also inherits the properties and methods of the
705
737
  * {@link HTMLElement} interface.
706
738
  *
739
+ * The element is sized like a replaced element such as `<video>`: a block-level box that the
740
+ * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
741
+ * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
742
+ * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
743
+ * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
744
+ *
707
745
  * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
708
746
  * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
709
747
  * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
710
748
  * not bubble.
749
+ *
750
+ * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
751
+ * could be created (for example, a browser with WebGL disabled). `message` names the requested
752
+ * backends and `error` holds the underlying failure. The element never becomes ready
753
+ * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
754
+ * re-inserting it retries the boot with its current attributes. Does not bubble.
711
755
  */
712
756
  class AppElement extends AsyncElement {
713
757
  /**
@@ -744,8 +788,9 @@ class AppElement extends AsyncElement {
744
788
  _bootGeneration = 0;
745
789
  /**
746
790
  * The elements backing this application's entities, keyed by the entity itself. Registered
747
- * by EntityElement at creation and removed when an entity is destroyed, this joins engine
748
- * scene nodes back to their owning elements by identity - never by name.
791
+ * by EntityElement at creation (and NodeElement at binding) and removed when an entity is
792
+ * destroyed or unbound, this joins engine scene nodes back to their owning elements by
793
+ * identity - never by name.
749
794
  */
750
795
  _entityElements = new Map();
751
796
  _picker = null;
@@ -766,6 +811,12 @@ class AppElement extends AsyncElement {
766
811
  };
767
812
  _app = null;
768
813
  _loadProgress = 0;
814
+ /**
815
+ * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
816
+ * the application exists, and disconnected on teardown. `null` where ResizeObserver is
817
+ * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
818
+ */
819
+ _resizeObserver = null;
769
820
  /**
770
821
  * The PlayCanvas application instance. `null` until the element is ready, and again once it
771
822
  * has been removed from the document — await {@link whenReady} or the element's `ready()`
@@ -792,8 +843,6 @@ class AppElement extends AsyncElement {
792
843
  */
793
844
  constructor() {
794
845
  super();
795
- // Bind methods to maintain 'this' context
796
- this._onWindowResize = this._onWindowResize.bind(this);
797
846
  // Track pointer listeners being added to and removed from descendant entities.
798
847
  // Registered once here rather than on every boot - the handlers no-op while there is no
799
848
  // canvas, and a re-booted element must not stack a second set.
@@ -804,6 +853,9 @@ class AppElement extends AsyncElement {
804
853
  }
805
854
  async connectedCallback() {
806
855
  const generation = ++this._bootGeneration;
856
+ // Installed before the loading bar is created: the bar anchors to this element, which
857
+ // these styles make a positioned block box
858
+ ensureBaseStyles();
807
859
  // Created before the first await, so the bar is visible while modules and the graphics
808
860
  // device are created, and exists before any disconnect could need to clean it up
809
861
  if (this._loadingBar && !this._bar) {
@@ -818,8 +870,11 @@ class AppElement extends AsyncElement {
818
870
  if (generation !== this._bootGeneration) {
819
871
  return;
820
872
  }
821
- // Create and append the canvas to the element
873
+ // Create and append the canvas, filling the element's content box - the page sizes the
874
+ // element, and everything else follows. touch-action: none keeps touch drags driving the
875
+ // engine's input handlers instead of scrolling the page.
822
876
  this._canvas = document.createElement('canvas');
877
+ this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
823
878
  this.appendChild(this._canvas);
824
879
  // Configure device types based on backend selection
825
880
  const backendToDeviceTypes = {
@@ -829,14 +884,44 @@ class AppElement extends AsyncElement {
829
884
  };
830
885
  const deviceTypes = backendToDeviceTypes[this._backend] || [];
831
886
  this._optionsLocked = true;
832
- const device = await playcanvas.createGraphicsDevice(this._canvas, {
833
- // @ts-ignore - alpha needs to be documented
834
- alpha: this._alpha,
835
- antialias: this._antialias,
836
- depth: this._depthBuffer,
837
- deviceTypes: deviceTypes,
838
- stencil: this._stencilBuffer
839
- });
887
+ // createGraphicsDevice appends its final null-device fallback to the array in place, so
888
+ // the requested list is captured now for the failure message.
889
+ const requested = deviceTypes.join(', ');
890
+ let device;
891
+ try {
892
+ device = await playcanvas.createGraphicsDevice(this._canvas, {
893
+ // @ts-ignore - alpha needs to be documented
894
+ alpha: this._alpha,
895
+ antialias: this._antialias,
896
+ depth: this._depthBuffer,
897
+ deviceTypes: deviceTypes,
898
+ stencil: this._stencilBuffer
899
+ });
900
+ }
901
+ catch (error) {
902
+ // The element may have been removed while device creation was failing. The teardown
903
+ // has already cleaned up, and the failure belongs to a boot that no longer owns the
904
+ // element.
905
+ if (generation !== this._bootGeneration) {
906
+ return;
907
+ }
908
+ // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
909
+ // zero - before announcing the failure. Readiness deliberately stays pending: nothing
910
+ // it would announce (the app, the entity hierarchy) exists, so a device-less element
911
+ // joins the documented never-ready cases and the failure surfaces through the error
912
+ // event instead.
913
+ if (this._canvas && this.contains(this._canvas)) {
914
+ this.removeChild(this._canvas);
915
+ }
916
+ this._canvas = null;
917
+ this._bar?.destroy();
918
+ this._bar = null;
919
+ const reason = error instanceof Error ? error.message : String(error);
920
+ const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
921
+ console.error(message, error);
922
+ this.dispatchEvent(new ErrorEvent('error', { message, error }));
923
+ return;
924
+ }
840
925
  // The element may have been removed while the device was created. disconnectedCallback
841
926
  // has already cleaned up the canvas; the device was created inside the await, so it is
842
927
  // this boot's to release.
@@ -915,9 +1000,19 @@ class AppElement extends AsyncElement {
915
1000
  const app = new playcanvas.AppBase(this._canvas);
916
1001
  this._app = app;
917
1002
  app.init(createOptions);
918
- app.setCanvasFillMode(playcanvas.FILLMODE_FILL_WINDOW);
1003
+ // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1004
+ // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1005
+ // from the canvas's client size
1006
+ app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
919
1007
  app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
920
1008
  this._pickerCreate();
1009
+ // Track the element's box rather than the window: containers resize without any window
1010
+ // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1011
+ // ResizeObserver - there, the resolution set above is the only sizing that happens.
1012
+ if (typeof ResizeObserver !== 'undefined') {
1013
+ this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1014
+ this._resizeObserver.observe(this);
1015
+ }
921
1016
  // Get all pc-asset elements that are direct children of the pc-app element
922
1017
  const assetElements = this.querySelectorAll(':scope > pc-asset');
923
1018
  for (const assetElement of Array.from(assetElements)) {
@@ -993,8 +1088,6 @@ class AppElement extends AsyncElement {
993
1088
  // Dismiss the bar only once a frame has actually rendered; ready fires before the
994
1089
  // first rAF tick
995
1090
  app.once('frameend', () => this._bar?.complete());
996
- // Handle window resize to keep the canvas responsive
997
- window.addEventListener('resize', this._onWindowResize);
998
1091
  this._onReady();
999
1092
  });
1000
1093
  }
@@ -1019,18 +1112,27 @@ class AppElement extends AsyncElement {
1019
1112
  // no longer holds.
1020
1113
  this._hierarchyReady = false;
1021
1114
  this._resetReady();
1022
- // Remove event listeners
1023
- window.removeEventListener('resize', this._onWindowResize);
1115
+ // Stop tracking the element's size
1116
+ this._resizeObserver?.disconnect();
1117
+ this._resizeObserver = null;
1024
1118
  // Remove the canvas
1025
1119
  if (this._canvas && this.contains(this._canvas)) {
1026
1120
  this.removeChild(this._canvas);
1027
1121
  this._canvas = null;
1028
1122
  }
1029
1123
  }
1030
- _onWindowResize() {
1031
- if (this.app) {
1032
- this.app.resizeCanvas();
1124
+ /**
1125
+ * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1126
+ * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1127
+ * an XR session presents - the session owns the buffer size.
1128
+ */
1129
+ _syncCanvasSize() {
1130
+ if (!this.app || this.app.xr?.active) {
1131
+ return;
1033
1132
  }
1133
+ this.app.updateCanvasSize();
1134
+ const { width, height } = this.app.graphicsDevice;
1135
+ this._picker?.resize(width, height);
1034
1136
  }
1035
1137
  _pickerCreate() {
1036
1138
  const { width, height } = this.app.graphicsDevice;
@@ -1050,7 +1152,7 @@ class AppElement extends AsyncElement {
1050
1152
  // created from onpointer* attributes when their elements were first upgraded, or
1051
1153
  // listeners carried over from before a re-boot)
1052
1154
  pointerEventTypes.forEach((type) => {
1053
- const anyListeners = Array.from(this.querySelectorAll('pc-entity')).some((entity) => entity._hasListeners(type));
1155
+ const anyListeners = Array.from(this.querySelectorAll('pc-entity, pc-node')).some((entity) => entity._hasListeners(type));
1054
1156
  if (anyListeners) {
1055
1157
  this._onPointerListenerAdded(type);
1056
1158
  }
@@ -1080,11 +1182,11 @@ class AppElement extends AsyncElement {
1080
1182
  };
1081
1183
  }
1082
1184
  /**
1083
- * Registers the element that created an entity. Called by EntityElement when it creates its
1084
- * entity.
1185
+ * Registers the element that fronts an entity. Called by EntityElement when it creates its
1186
+ * entity, and by NodeElement when it binds one.
1085
1187
  *
1086
1188
  * @param entity - The entity.
1087
- * @param element - The element that created it.
1189
+ * @param element - The element that fronts it.
1088
1190
  * @internal
1089
1191
  */
1090
1192
  _registerEntityElement(entity, element) {
@@ -1100,21 +1202,22 @@ class AppElement extends AsyncElement {
1100
1202
  this._entityElements.delete(entity);
1101
1203
  }
1102
1204
  /**
1103
- * Returns the `<pc-entity>` element whose backing entity is `entity`, or `null` if the
1104
- * entity was not created by an element of this application - for example, a node inside a
1105
- * model's instantiated hierarchy, or an entity created through the engine API.
1205
+ * Returns the `<pc-entity>` or `<pc-node>` element whose backing entity is `entity`, or
1206
+ * `null` if the entity is not fronted by an element of this application - for example, an
1207
+ * unbound node inside a model's instantiated hierarchy, or an entity created through the
1208
+ * engine API.
1106
1209
  *
1107
1210
  * @param entity - The entity to look up.
1108
- * @returns The element backing the entity, or `null`.
1211
+ * @returns The element fronting the entity, or `null`.
1109
1212
  */
1110
1213
  elementFromEntity(entity) {
1111
1214
  return this._entityElements.get(entity) ?? null;
1112
1215
  }
1113
1216
  /**
1114
1217
  * Resolves the element that owns a picked node: the nearest node up the parent chain -
1115
- * starting with the node itself - that was created by a `<pc-entity>` of this application.
1116
- * A hit inside a model's instantiated hierarchy therefore resolves to the element hosting
1117
- * the model.
1218
+ * starting with the node itself - that is fronted by a `<pc-entity>` or `<pc-node>` of this
1219
+ * application. A hit inside a model's instantiated hierarchy therefore resolves to the
1220
+ * nearest bound `<pc-node>`, or failing that the element hosting the model.
1118
1221
  *
1119
1222
  * @param node - The picked node, or `null`.
1120
1223
  * @returns The owning element, or `null`.
@@ -1246,7 +1349,7 @@ class AppElement extends AsyncElement {
1246
1349
  }
1247
1350
  }
1248
1351
  _onPointerListenerRemoved(type) {
1249
- const hasListeners = Array.from(this.querySelectorAll('pc-entity')).some((entity) => entity._hasListeners(type));
1352
+ const hasListeners = Array.from(this.querySelectorAll('pc-entity, pc-node')).some((entity) => entity._hasListeners(type));
1250
1353
  if (!hasListeners && this._canvas) {
1251
1354
  this._hasPointerListeners[type] = false;
1252
1355
  const handler = type === 'pointerenter' || type === 'pointerleave'
@@ -1367,7 +1470,7 @@ class AppElement extends AsyncElement {
1367
1470
  this._maxPixelRatio = value;
1368
1471
  if (this.app) {
1369
1472
  this.app.graphicsDevice.maxPixelRatio = value;
1370
- this.app.resizeCanvas();
1473
+ this._syncCanvasSize();
1371
1474
  }
1372
1475
  }
1373
1476
  /**
@@ -1424,6 +1527,125 @@ class AppElement extends AsyncElement {
1424
1527
  }
1425
1528
  customElements.define('pc-app', AppElement);
1426
1529
 
1530
+ /**
1531
+ * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1532
+ * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1533
+ * @ignore
1534
+ */
1535
+ const POINTER_ATTRIBUTES = [
1536
+ 'onpointerenter',
1537
+ 'onpointerleave',
1538
+ 'onpointerdown',
1539
+ 'onpointerup',
1540
+ 'onpointermove'
1541
+ ];
1542
+ /**
1543
+ * The base class for elements that front an engine {@link Entity}: `<pc-entity>`, which creates
1544
+ * one, and `<pc-node>`, which binds to one inside a model's instantiated hierarchy. It carries
1545
+ * what both need — the `entity` contract, registration with the owning application (which joins
1546
+ * picked scene nodes back to elements by identity, never by name), and the pointer listener
1547
+ * bookkeeping that lets the application lazily attach its canvas handlers.
1548
+ */
1549
+ class EntityBaseElement extends AsyncElement {
1550
+ _entity = null;
1551
+ /**
1552
+ * The application element this entity is registered with, cached at registration time so the
1553
+ * entity can be unregistered even once this element has left the DOM.
1554
+ */
1555
+ _appElement = null;
1556
+ /**
1557
+ * The pointer event listeners for the entity.
1558
+ */
1559
+ _listeners = {};
1560
+ /**
1561
+ * The event types for which an inline `onpointer*` attribute is currently present.
1562
+ */
1563
+ _inlineHandlerTypes = new Set();
1564
+ /**
1565
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once the
1566
+ * entity is gone — await {@link whenReady} or the element's `ready()` promise before
1567
+ * accessing it.
1568
+ * @returns The entity instance, or `null`.
1569
+ */
1570
+ get entity() {
1571
+ return this._entity;
1572
+ }
1573
+ /**
1574
+ * Registers `entity` as this element's backing entity with the owning application, which
1575
+ * joins engine nodes back to elements by identity (never by name).
1576
+ *
1577
+ * @param entity - The entity to register.
1578
+ */
1579
+ _registerEntity(entity) {
1580
+ this._appElement = this.closestApp;
1581
+ this._appElement?._registerEntityElement(entity, this);
1582
+ }
1583
+ /**
1584
+ * Removes the registration for `entity`.
1585
+ *
1586
+ * @param entity - The entity to unregister.
1587
+ */
1588
+ _unregisterEntity(entity) {
1589
+ this._appElement?._unregisterEntityElement(entity);
1590
+ this._appElement = null;
1591
+ }
1592
+ /**
1593
+ * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
1594
+ * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
1595
+ * the previous handler and removing it removes the handler, exactly like `onclick` on any
1596
+ * HTML element. But because they bypass {@link addEventListener}, the connect/disconnect
1597
+ * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
1598
+ * kept in sync here.
1599
+ *
1600
+ * @param name - The attribute name (e.g. 'onpointerdown').
1601
+ * @param value - The attribute value, or `null` when the attribute has been removed.
1602
+ */
1603
+ _updateInlineHandler(name, value) {
1604
+ const type = name.substring(2);
1605
+ const had = this._inlineHandlerTypes.has(type);
1606
+ const has = value !== null;
1607
+ if (has && !had) {
1608
+ this._inlineHandlerTypes.add(type);
1609
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1610
+ }
1611
+ else if (!has && had) {
1612
+ this._inlineHandlerTypes.delete(type);
1613
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1614
+ }
1615
+ }
1616
+ addEventListener(type, listener, options) {
1617
+ if (!this._listeners[type]) {
1618
+ this._listeners[type] = [];
1619
+ }
1620
+ this._listeners[type].push(listener);
1621
+ super.addEventListener(type, listener, options);
1622
+ if (type.startsWith('pointer')) {
1623
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1624
+ }
1625
+ }
1626
+ removeEventListener(type, listener, options) {
1627
+ if (this._listeners[type]) {
1628
+ this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
1629
+ }
1630
+ super.removeEventListener(type, listener, options);
1631
+ if (type.startsWith('pointer')) {
1632
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1633
+ }
1634
+ }
1635
+ /**
1636
+ * Whether the element has a listener for an event type, registered either with
1637
+ * {@link addEventListener} or with the matching inline `onpointer*` attribute. Read by the
1638
+ * containing `<pc-app>` element to gate pointer event synthesis.
1639
+ *
1640
+ * @param type - The event type.
1641
+ * @returns Whether a listener is registered.
1642
+ * @internal
1643
+ */
1644
+ _hasListeners(type) {
1645
+ return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1646
+ }
1647
+ }
1648
+
1427
1649
  /**
1428
1650
  * The EntityElement interface provides properties and methods for manipulating
1429
1651
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-entity/ | `<pc-entity>`} elements.
@@ -1448,7 +1670,7 @@ customElements.define('pc-app', AppElement);
1448
1670
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the entity.
1449
1671
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the entity.
1450
1672
  */
1451
- class EntityElement extends AsyncElement {
1673
+ class EntityElement extends EntityBaseElement {
1452
1674
  /**
1453
1675
  * Whether the entity is enabled.
1454
1676
  */
@@ -1473,33 +1695,10 @@ class EntityElement extends AsyncElement {
1473
1695
  * The tags of the entity.
1474
1696
  */
1475
1697
  _tags = [];
1476
- /**
1477
- * The pointer event listeners for the entity.
1478
- */
1479
- _listeners = {};
1480
- /**
1481
- * The event types for which an inline `onpointer*` attribute is currently present.
1482
- */
1483
- _inlineHandlerTypes = new Set();
1484
1698
  /**
1485
1699
  * Whether the hierarchy has been built for this entity.
1486
1700
  */
1487
1701
  _built = false;
1488
- _entity = null;
1489
- /**
1490
- * The application element this entity is registered with, cached at creation time so the
1491
- * entity can be unregistered even once this element has left the DOM.
1492
- */
1493
- _appElement = null;
1494
- /**
1495
- * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
1496
- * been removed from the document — await {@link whenReady} or the element's `ready()`
1497
- * promise before accessing it.
1498
- * @returns The entity instance, or `null`.
1499
- */
1500
- get entity() {
1501
- return this._entity;
1502
- }
1503
1702
  /**
1504
1703
  * Creates the backing entity. Called by the containing `<pc-app>` element during its boot
1505
1704
  * sweep, and on connection for elements inserted while the application is already running.
@@ -1527,13 +1726,11 @@ class EntityElement extends AsyncElement {
1527
1726
  if (this._tags.length > 0) {
1528
1727
  entity.tags.add(this._tags);
1529
1728
  }
1530
- // Register with the owning application, which joins engine nodes back to elements by
1531
- // identity (never by name), and hook the entity's destruction. The engine fires 'destroy'
1532
- // for every entity in a destroyed subtree, so the element learns of its entity's death no
1533
- // matter who causes it: this element, an ancestor, the whole application, or a user
1534
- // script calling entity.destroy().
1535
- this._appElement = this.closestApp;
1536
- this._appElement?._registerEntityElement(entity, this);
1729
+ // Register with the owning application and hook the entity's destruction. The engine
1730
+ // fires 'destroy' for every entity in a destroyed subtree, so the element learns of its
1731
+ // entity's death no matter who causes it: this element, an ancestor, the whole
1732
+ // application, or a user script calling entity.destroy().
1733
+ this._registerEntity(entity);
1537
1734
  entity.once('destroy', this._onEntityDestroy, this);
1538
1735
  }
1539
1736
  /**
@@ -1546,17 +1743,16 @@ class EntityElement extends AsyncElement {
1546
1743
  * @param entity - The entity that was destroyed.
1547
1744
  */
1548
1745
  _onEntityDestroy(entity) {
1549
- this._appElement?._unregisterEntityElement(entity);
1550
- this._appElement = null;
1746
+ this._unregisterEntity(entity);
1551
1747
  this._entity = null;
1552
1748
  this._built = false;
1553
1749
  this._resetReady();
1554
1750
  }
1555
1751
  /**
1556
- * Parents the backing entity: under the entity of the nearest ancestor `<pc-entity>` when
1557
- * there is one, and under the application root otherwise. Called by the containing `<pc-app>`
1558
- * element once a sweep has created every entity, so a parent's existence never depends on
1559
- * document order.
1752
+ * Parents the backing entity: under the entity of the nearest ancestor `<pc-entity>` or
1753
+ * `<pc-node>` when there is one, and under the application root otherwise. Called by the
1754
+ * containing `<pc-app>` element once a sweep has created every entity, so a parent's
1755
+ * existence never depends on document order.
1560
1756
  *
1561
1757
  * @param app - The application whose root adopts parentless entities.
1562
1758
  * @internal
@@ -1564,8 +1760,14 @@ class EntityElement extends AsyncElement {
1564
1760
  _buildHierarchy(app) {
1565
1761
  if (!this.entity || this._built)
1566
1762
  return;
1567
- this._built = true;
1568
1763
  const closestEntity = this.closestEntity;
1764
+ // A host element without an entity is an unresolved `<pc-node>`: building now would
1765
+ // mis-anchor this entity to the application root while the host is still resolving.
1766
+ // Stay unbuilt - the host drives this subtree itself once it binds.
1767
+ if (closestEntity && !closestEntity.entity) {
1768
+ return;
1769
+ }
1770
+ this._built = true;
1569
1771
  if (closestEntity?.entity) {
1570
1772
  closestEntity.entity.addChild(this.entity);
1571
1773
  }
@@ -1711,44 +1913,8 @@ class EntityElement extends AsyncElement {
1711
1913
  get tags() {
1712
1914
  return this._tags;
1713
1915
  }
1714
- /**
1715
- * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
1716
- * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
1717
- * the previous handler and removing it removes the handler, exactly like `onclick` on any
1718
- * HTML element. But because they bypass {@link addEventListener}, the connect/disconnect
1719
- * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
1720
- * kept in sync here.
1721
- *
1722
- * @param name - The attribute name (e.g. 'onpointerdown').
1723
- * @param value - The attribute value, or `null` when the attribute has been removed.
1724
- */
1725
- _updateInlineHandler(name, value) {
1726
- const type = name.substring(2);
1727
- const had = this._inlineHandlerTypes.has(type);
1728
- const has = value !== null;
1729
- if (has && !had) {
1730
- this._inlineHandlerTypes.add(type);
1731
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1732
- }
1733
- else if (!has && had) {
1734
- this._inlineHandlerTypes.delete(type);
1735
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1736
- }
1737
- }
1738
1916
  static get observedAttributes() {
1739
- return [
1740
- 'enabled',
1741
- 'name',
1742
- 'position',
1743
- 'rotation',
1744
- 'scale',
1745
- 'tags',
1746
- 'onpointerenter',
1747
- 'onpointerleave',
1748
- 'onpointerdown',
1749
- 'onpointerup',
1750
- 'onpointermove'
1751
- ];
1917
+ return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
1752
1918
  }
1753
1919
  attributeChangedCallback(name, _oldValue, newValue) {
1754
1920
  switch (name) {
@@ -1779,37 +1945,6 @@ class EntityElement extends AsyncElement {
1779
1945
  break;
1780
1946
  }
1781
1947
  }
1782
- addEventListener(type, listener, options) {
1783
- if (!this._listeners[type]) {
1784
- this._listeners[type] = [];
1785
- }
1786
- this._listeners[type].push(listener);
1787
- super.addEventListener(type, listener, options);
1788
- if (type.startsWith('pointer')) {
1789
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1790
- }
1791
- }
1792
- removeEventListener(type, listener, options) {
1793
- if (this._listeners[type]) {
1794
- this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
1795
- }
1796
- super.removeEventListener(type, listener, options);
1797
- if (type.startsWith('pointer')) {
1798
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1799
- }
1800
- }
1801
- /**
1802
- * Whether the element has a listener for an event type, registered either with
1803
- * {@link addEventListener} or with the matching inline `onpointer*` attribute. Read by the
1804
- * containing `<pc-app>` element to gate pointer event synthesis.
1805
- *
1806
- * @param type - The event type.
1807
- * @returns Whether a listener is registered.
1808
- * @internal
1809
- */
1810
- _hasListeners(type) {
1811
- return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1812
- }
1813
1948
  }
1814
1949
  customElements.define('pc-entity', EntityElement);
1815
1950
 
@@ -1994,6 +2129,48 @@ const renderModes = new Map([
1994
2129
  ['sliced', playcanvas.SPRITE_RENDERMODE_SLICED],
1995
2130
  ['tiled', playcanvas.SPRITE_RENDERMODE_TILED]
1996
2131
  ]);
2132
+ const addressModes = new Map([
2133
+ ['repeat', playcanvas.ADDRESS_REPEAT],
2134
+ ['clamp', playcanvas.ADDRESS_CLAMP_TO_EDGE],
2135
+ ['mirror', playcanvas.ADDRESS_MIRRORED_REPEAT]
2136
+ ]);
2137
+ const minFilterModes = new Map([
2138
+ ['nearest', playcanvas.FILTER_NEAREST],
2139
+ ['linear', playcanvas.FILTER_LINEAR],
2140
+ ['nearest-mip-nearest', playcanvas.FILTER_NEAREST_MIPMAP_NEAREST],
2141
+ ['linear-mip-nearest', playcanvas.FILTER_LINEAR_MIPMAP_NEAREST],
2142
+ ['nearest-mip-linear', playcanvas.FILTER_NEAREST_MIPMAP_LINEAR],
2143
+ ['linear-mip-linear', playcanvas.FILTER_LINEAR_MIPMAP_LINEAR]
2144
+ ]);
2145
+ const magFilterModes = new Map([
2146
+ ['nearest', playcanvas.FILTER_NEAREST],
2147
+ ['linear', playcanvas.FILTER_LINEAR]
2148
+ ]);
2149
+ // The engine's texture JSON spells the filter names with underscores ('linear_mip_linear'); the
2150
+ // attribute values are kebab-case like every other enum attribute in this library. The address
2151
+ // mode names contain no dashes, so for them the rename is the identity.
2152
+ const toTextureJson = (name) => name.replace(/-/g, '_');
2153
+ // Engine Texture constructor defaults, restored on a loaded texture when a texture option
2154
+ // attribute is removed.
2155
+ const textureOptionDefaults = {
2156
+ addressU: playcanvas.ADDRESS_REPEAT,
2157
+ addressV: playcanvas.ADDRESS_REPEAT,
2158
+ anisotropy: 1,
2159
+ flipY: false,
2160
+ magFilter: playcanvas.FILTER_LINEAR,
2161
+ minFilter: playcanvas.FILTER_LINEAR_MIPMAP_LINEAR,
2162
+ mipmaps: true,
2163
+ srgb: false
2164
+ };
2165
+ // Attributes that only apply to certain asset types, used to warn when one is set on an asset of
2166
+ // any other type (where it would otherwise be silently ignored).
2167
+ const typeScopedAttributes = [
2168
+ [
2169
+ ['address-u', 'address-v', 'anisotropy', 'flip-y', 'mag-filter', 'min-filter', 'mipmaps', 'srgb'],
2170
+ ['texture', 'textureatlas']
2171
+ ],
2172
+ [['atlas', 'frame-keys', 'pixels-per-unit', 'render-mode'], ['sprite']]
2173
+ ];
1997
2174
  const extToType = new Map([
1998
2175
  ['bin', 'binary'],
1999
2176
  ['css', 'css'],
@@ -2050,8 +2227,16 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
2050
2227
  * immediately unless `lazy`. A `pc-asset` must be a direct child of `pc-app` — elements placed
2051
2228
  * elsewhere, or with an unsupported asset type, never become ready.
2052
2229
  *
2053
- * Apart from `lazy`, these attributes are read once when the asset is created, so changing them
2054
- * later has no effect.
2230
+ * For `texture` and `textureatlas` assets, the texture options (`address-u`, `address-v`,
2231
+ * `min-filter`, `mag-filter`, `anisotropy`, `mipmaps`, `srgb`, `flip-y`) apply when the texture is
2232
+ * created and — like `lazy` — are observed: changing one updates a texture that has already
2233
+ * loaded, and removing one restores the engine default. Changing `srgb` or `mipmaps` on a loaded
2234
+ * texture recreates the underlying GPU resource, so prefer declaring those up front. Each option
2235
+ * overrides the matching key in the `data` JSON; options left unset write nothing, leaving the
2236
+ * engine's per-format defaults in force.
2237
+ *
2238
+ * Apart from `lazy` and the texture options, these attributes are read once when the asset is
2239
+ * created, so changing them later has no effect.
2055
2240
  *
2056
2241
  * @attribute {string} id - The identifier used to reference the asset from other elements.
2057
2242
  * @attribute {string} src - The URL of the asset to load.
@@ -2073,7 +2258,15 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
2073
2258
  * not that it succeeded.
2074
2259
  */
2075
2260
  class AssetElement extends AsyncElement {
2261
+ _addressU = null;
2262
+ _addressV = null;
2263
+ _anisotropy = null;
2264
+ _flipY = null;
2076
2265
  _lazy = false;
2266
+ _magFilter = null;
2267
+ _minFilter = null;
2268
+ _mipmaps = null;
2269
+ _srgb = null;
2077
2270
  /**
2078
2271
  * The asset that is loaded. Available once the element is ready — await
2079
2272
  * {@link whenReady} or the element's `ready()` promise before accessing it.
@@ -2144,6 +2337,15 @@ class AssetElement extends AsyncElement {
2144
2337
  console.warn(`Unsupported asset type: ${src}`);
2145
2338
  return;
2146
2339
  }
2340
+ // Attributes scoped to other asset types have no effect here - say so rather than
2341
+ // failing silently.
2342
+ const inapplicable = typeScopedAttributes
2343
+ .filter(([, types]) => !types.includes(type))
2344
+ .flatMap(([attributes]) => attributes)
2345
+ .filter((attribute) => this.hasAttribute(attribute));
2346
+ if (inapplicable.length > 0) {
2347
+ console.warn(`pc-asset '${id || src}' has attributes that do not apply to asset type '${type}' and are ignored: ${inapplicable.join(', ')}`);
2348
+ }
2147
2349
  // Optional inline asset data, used by data-driven assets such as texture atlases (frame
2148
2350
  // definitions) and sprites (atlas reference, frame keys, etc.).
2149
2351
  const data = this._buildData(type);
@@ -2171,9 +2373,11 @@ class AssetElement extends AsyncElement {
2171
2373
  this.asset.on('error', this._onAssetError, this);
2172
2374
  }
2173
2375
  /**
2174
- * Builds the `data` object for the asset from an optional inline `data` attribute (JSON) and,
2175
- * for sprites, from the convenience attributes (`atlas`, `frame-keys`, `pixels-per-unit`,
2176
- * `render-mode`). Returns `undefined` when there is no data to apply.
2376
+ * Builds the `data` object for the asset from an optional inline `data` attribute (JSON), the
2377
+ * texture option attributes (for `texture` and `textureatlas` assets), and the sprite
2378
+ * convenience attributes (`atlas`, `frame-keys`, `pixels-per-unit`, `render-mode`). An
2379
+ * attribute overrides the matching `data` JSON key. Returns `undefined` when there is no data
2380
+ * to apply.
2177
2381
  * @param type - The resolved asset type.
2178
2382
  * @returns The asset data, or `undefined`.
2179
2383
  */
@@ -2188,6 +2392,37 @@ class AssetElement extends AsyncElement {
2188
2392
  console.warn(`Invalid 'data' JSON on pc-asset: ${dataAttr}`);
2189
2393
  }
2190
2394
  }
2395
+ if (type === 'texture' || type === 'textureatlas') {
2396
+ data = data ?? {};
2397
+ // Only options the user actually set are written: the engine reads these keys with
2398
+ // hasOwnProperty semantics, and an absent key leaves its per-format default (an HDR's
2399
+ // 'rgbe' type, a KTX2's transcoded format) in force.
2400
+ if (this._addressU !== null) {
2401
+ data.addressu = this._addressU;
2402
+ }
2403
+ if (this._addressV !== null) {
2404
+ data.addressv = this._addressV;
2405
+ }
2406
+ if (this._anisotropy !== null) {
2407
+ data.anisotropy = this._anisotropy;
2408
+ }
2409
+ if (this._flipY !== null) {
2410
+ // 'flipY' is the one camelCase key in the engine's texture JSON
2411
+ data.flipY = this._flipY;
2412
+ }
2413
+ if (this._magFilter !== null) {
2414
+ data.magfilter = toTextureJson(this._magFilter);
2415
+ }
2416
+ if (this._minFilter !== null) {
2417
+ data.minfilter = toTextureJson(this._minFilter);
2418
+ }
2419
+ if (this._mipmaps !== null) {
2420
+ data.mipmaps = this._mipmaps;
2421
+ }
2422
+ if (this._srgb !== null) {
2423
+ data.srgb = this._srgb;
2424
+ }
2425
+ }
2191
2426
  if (type === 'sprite') {
2192
2427
  data = data ?? {};
2193
2428
  // Resolve the referenced texture atlas to its (numeric) asset id. The atlas must be
@@ -2221,6 +2456,56 @@ class AssetElement extends AsyncElement {
2221
2456
  }
2222
2457
  return data;
2223
2458
  }
2459
+ /**
2460
+ * Returns the engine texture behind this asset, when there is one: the resource itself for a
2461
+ * `texture` asset, the atlas's texture for a `textureatlas` asset, `null` otherwise
2462
+ * (including before the asset has loaded).
2463
+ * @returns The texture, or `null`.
2464
+ */
2465
+ _texture() {
2466
+ const asset = this.asset;
2467
+ if (!asset?.resource)
2468
+ return null;
2469
+ if (asset.type === 'texture')
2470
+ return asset.resource;
2471
+ if (asset.type === 'textureatlas')
2472
+ return asset.resource.texture ?? null;
2473
+ return null;
2474
+ }
2475
+ /**
2476
+ * Writes one texture option through to the created asset, if any. The engine-JSON key is
2477
+ * written into `asset.data`, mutated in place - replacing the whole object would make the
2478
+ * registry re-patch every key, and a re-patched `srgb` or `mipmaps` recreates the texture
2479
+ * even when unchanged. The in-place key is what a not-yet-started load reads at texture
2480
+ * construction, and what any later reload reads. When the texture already exists, the
2481
+ * corresponding property is assigned directly; `null` (attribute removed) deletes the key
2482
+ * and restores the engine default. Assets of any other type are left untouched.
2483
+ *
2484
+ * @param key - The engine texture JSON key in `asset.data`.
2485
+ * @param property - The Texture property to assign.
2486
+ * @param dataValue - The engine-JSON value for `asset.data`, or `null` to delete the key.
2487
+ * @param textureValue - The value for the Texture property, or `null` for the engine default.
2488
+ */
2489
+ _applyTextureOption(key, property, dataValue, textureValue) {
2490
+ const asset = this.asset;
2491
+ if (!asset || (asset.type !== 'texture' && asset.type !== 'textureatlas'))
2492
+ return;
2493
+ const data = asset.data;
2494
+ if (dataValue === null) {
2495
+ delete data[key];
2496
+ }
2497
+ else {
2498
+ data[key] = dataValue;
2499
+ }
2500
+ const texture = this._texture();
2501
+ if (texture) {
2502
+ // Every option here is a number- or boolean-valued Texture property; the
2503
+ // value/property pairing is fixed by the callers, which TypeScript cannot see
2504
+ // through the union.
2505
+ texture[property] =
2506
+ textureValue ?? textureOptionDefaults[property];
2507
+ }
2508
+ }
2224
2509
  _destroyAsset() {
2225
2510
  if (this.asset) {
2226
2511
  // A caller that keeps the Asset alive must not dispatch on a removed element
@@ -2233,22 +2518,164 @@ class AssetElement extends AsyncElement {
2233
2518
  }
2234
2519
  }
2235
2520
  /**
2236
- * Sets whether the asset should be loaded lazily.
2237
- * @param value - The lazy loading flag.
2521
+ * Sets the texture's horizontal (U) address mode: how texture coordinates outside the 0 to 1
2522
+ * range sample the texture. Applies to `texture` and `textureatlas` assets, both when the
2523
+ * texture is created and after it has loaded.
2524
+ * @param value - The address mode, or `null` to use the engine default of 'repeat'.
2238
2525
  */
2239
- set lazy(value) {
2240
- this._lazy = value;
2241
- if (this.asset) {
2242
- this.asset.preload = !value;
2243
- }
2526
+ set addressU(value) {
2527
+ this._addressU = value;
2528
+ const constant = value === null ? null : (addressModes.get(value) ?? playcanvas.ADDRESS_REPEAT);
2529
+ this._applyTextureOption('addressu', 'addressU', value, constant);
2244
2530
  }
2245
2531
  /**
2246
- * Gets whether the asset should be loaded lazily.
2247
- * @returns The lazy loading flag.
2532
+ * Gets the texture's horizontal (U) address mode.
2533
+ * @returns The address mode, or `null` when unset.
2248
2534
  */
2249
- get lazy() {
2535
+ get addressU() {
2536
+ return this._addressU;
2537
+ }
2538
+ /**
2539
+ * Sets the texture's vertical (V) address mode: how texture coordinates outside the 0 to 1
2540
+ * range sample the texture. Applies to `texture` and `textureatlas` assets, both when the
2541
+ * texture is created and after it has loaded.
2542
+ * @param value - The address mode, or `null` to use the engine default of 'repeat'.
2543
+ */
2544
+ set addressV(value) {
2545
+ this._addressV = value;
2546
+ const constant = value === null ? null : (addressModes.get(value) ?? playcanvas.ADDRESS_REPEAT);
2547
+ this._applyTextureOption('addressv', 'addressV', value, constant);
2548
+ }
2549
+ /**
2550
+ * Gets the texture's vertical (V) address mode.
2551
+ * @returns The address mode, or `null` when unset.
2552
+ */
2553
+ get addressV() {
2554
+ return this._addressV;
2555
+ }
2556
+ /**
2557
+ * Sets the texture's maximum anisotropic filtering level, which improves quality at oblique
2558
+ * viewing angles. Applies to `texture` and `textureatlas` assets, both when the texture is
2559
+ * created and after it has loaded.
2560
+ * @param value - The anisotropy level, or `null` to use the engine default of 1.
2561
+ */
2562
+ set anisotropy(value) {
2563
+ this._anisotropy = value;
2564
+ this._applyTextureOption('anisotropy', 'anisotropy', value, value);
2565
+ }
2566
+ /**
2567
+ * Gets the texture's maximum anisotropic filtering level.
2568
+ * @returns The anisotropy level, or `null` when unset.
2569
+ */
2570
+ get anisotropy() {
2571
+ return this._anisotropy;
2572
+ }
2573
+ /**
2574
+ * Sets whether the texture's image data is flipped vertically at upload. Applies to `texture`
2575
+ * and `textureatlas` assets, both when the texture is created and after it has loaded.
2576
+ * @param value - The flip flag, or `null` to use the engine default of `false`.
2577
+ */
2578
+ set flipY(value) {
2579
+ this._flipY = value;
2580
+ this._applyTextureOption('flipY', 'flipY', value, value);
2581
+ }
2582
+ /**
2583
+ * Gets whether the texture's image data is flipped vertically at upload.
2584
+ * @returns The flip flag, or `null` when unset.
2585
+ */
2586
+ get flipY() {
2587
+ return this._flipY;
2588
+ }
2589
+ /**
2590
+ * Sets whether the asset should be loaded lazily.
2591
+ * @param value - The lazy loading flag.
2592
+ */
2593
+ set lazy(value) {
2594
+ this._lazy = value;
2595
+ if (this.asset) {
2596
+ this.asset.preload = !value;
2597
+ }
2598
+ }
2599
+ /**
2600
+ * Gets whether the asset should be loaded lazily.
2601
+ * @returns The lazy loading flag.
2602
+ */
2603
+ get lazy() {
2250
2604
  return this._lazy;
2251
2605
  }
2606
+ /**
2607
+ * Sets the texture's magnification filter, used when the texture is displayed larger than its
2608
+ * source size. Applies to `texture` and `textureatlas` assets, both when the texture is
2609
+ * created and after it has loaded.
2610
+ * @param value - The filter, or `null` to use the engine default of 'linear'.
2611
+ */
2612
+ set magFilter(value) {
2613
+ this._magFilter = value;
2614
+ const json = value === null ? null : toTextureJson(value);
2615
+ const constant = value === null ? null : (magFilterModes.get(value) ?? playcanvas.FILTER_LINEAR);
2616
+ this._applyTextureOption('magfilter', 'magFilter', json, constant);
2617
+ }
2618
+ /**
2619
+ * Gets the texture's magnification filter.
2620
+ * @returns The filter, or `null` when unset.
2621
+ */
2622
+ get magFilter() {
2623
+ return this._magFilter;
2624
+ }
2625
+ /**
2626
+ * Sets the texture's minification filter, used when the texture is displayed smaller than its
2627
+ * source size. The mip variants blend within (and, for the second `linear`, between) mipmap
2628
+ * levels. Applies to `texture` and `textureatlas` assets, both when the texture is created
2629
+ * and after it has loaded.
2630
+ * @param value - The filter, or `null` to use the engine default of 'linear-mip-linear'.
2631
+ */
2632
+ set minFilter(value) {
2633
+ this._minFilter = value;
2634
+ const json = value === null ? null : toTextureJson(value);
2635
+ const constant = value === null ? null : (minFilterModes.get(value) ?? playcanvas.FILTER_LINEAR_MIPMAP_LINEAR);
2636
+ this._applyTextureOption('minfilter', 'minFilter', json, constant);
2637
+ }
2638
+ /**
2639
+ * Gets the texture's minification filter.
2640
+ * @returns The filter, or `null` when unset.
2641
+ */
2642
+ get minFilter() {
2643
+ return this._minFilter;
2644
+ }
2645
+ /**
2646
+ * Sets whether the texture generates and uses mipmaps. Changing this on a loaded texture
2647
+ * recreates the underlying GPU resource, so prefer declaring it up front. Applies to
2648
+ * `texture` and `textureatlas` assets.
2649
+ * @param value - The mipmaps flag, or `null` to use the engine default of `true`.
2650
+ */
2651
+ set mipmaps(value) {
2652
+ this._mipmaps = value;
2653
+ this._applyTextureOption('mipmaps', 'mipmaps', value, value);
2654
+ }
2655
+ /**
2656
+ * Gets whether the texture generates and uses mipmaps.
2657
+ * @returns The mipmaps flag, or `null` when unset.
2658
+ */
2659
+ get mipmaps() {
2660
+ return this._mipmaps;
2661
+ }
2662
+ /**
2663
+ * Sets whether the texture holds sRGB (gamma-encoded) color data, enabling hardware gamma
2664
+ * decode. Free when set before the texture loads; changing it on a loaded texture recreates
2665
+ * the underlying GPU resource. Applies to `texture` and `textureatlas` assets.
2666
+ * @param value - The sRGB flag, or `null` to use the engine default of `false`.
2667
+ */
2668
+ set srgb(value) {
2669
+ this._srgb = value;
2670
+ this._applyTextureOption('srgb', 'srgb', value, value);
2671
+ }
2672
+ /**
2673
+ * Gets whether the texture holds sRGB (gamma-encoded) color data.
2674
+ * @returns The sRGB flag, or `null` when unset.
2675
+ */
2676
+ get srgb() {
2677
+ return this._srgb;
2678
+ }
2252
2679
  /**
2253
2680
  * Returns the {@link Asset} created by the `<pc-asset>` element with the given `id`, or
2254
2681
  * `undefined` if there is no such element or its asset has not been created yet.
@@ -2261,11 +2688,91 @@ class AssetElement extends AsyncElement {
2261
2688
  return assetElement?.asset;
2262
2689
  }
2263
2690
  static get observedAttributes() {
2264
- return ['lazy'];
2691
+ return [
2692
+ 'address-u',
2693
+ 'address-v',
2694
+ 'anisotropy',
2695
+ 'flip-y',
2696
+ 'lazy',
2697
+ 'mag-filter',
2698
+ 'min-filter',
2699
+ 'mipmaps',
2700
+ 'srgb'
2701
+ ];
2265
2702
  }
2266
2703
  attributeChangedCallback(name, _oldValue, newValue) {
2267
- if (name === 'lazy') {
2268
- this.lazy = parseBool(newValue, false);
2704
+ // Each texture option keeps its parse* call as the branch's first assignment (the CEM
2705
+ // manifest derives the attribute's type and default from it - a ternary would degrade
2706
+ // both to plain string) and treats a removed attribute (null) as a reset to unset,
2707
+ // which restores the engine default on a loaded texture.
2708
+ switch (name) {
2709
+ case 'address-u':
2710
+ if (newValue !== null) {
2711
+ this.addressU = parseEnum(newValue, addressModes, 'repeat', name);
2712
+ }
2713
+ else {
2714
+ this.addressU = null;
2715
+ }
2716
+ break;
2717
+ case 'address-v':
2718
+ if (newValue !== null) {
2719
+ this.addressV = parseEnum(newValue, addressModes, 'repeat', name);
2720
+ }
2721
+ else {
2722
+ this.addressV = null;
2723
+ }
2724
+ break;
2725
+ case 'anisotropy':
2726
+ if (newValue !== null) {
2727
+ this.anisotropy = parseNumber(newValue, 1, name);
2728
+ }
2729
+ else {
2730
+ this.anisotropy = null;
2731
+ }
2732
+ break;
2733
+ case 'flip-y':
2734
+ if (newValue !== null) {
2735
+ this.flipY = parseBool(newValue, false);
2736
+ }
2737
+ else {
2738
+ this.flipY = null;
2739
+ }
2740
+ break;
2741
+ case 'lazy':
2742
+ this.lazy = parseBool(newValue, false);
2743
+ break;
2744
+ case 'mag-filter':
2745
+ if (newValue !== null) {
2746
+ this.magFilter = parseEnum(newValue, magFilterModes, 'linear', name);
2747
+ }
2748
+ else {
2749
+ this.magFilter = null;
2750
+ }
2751
+ break;
2752
+ case 'min-filter':
2753
+ if (newValue !== null) {
2754
+ this.minFilter = parseEnum(newValue, minFilterModes, 'linear-mip-linear', name);
2755
+ }
2756
+ else {
2757
+ this.minFilter = null;
2758
+ }
2759
+ break;
2760
+ case 'mipmaps':
2761
+ if (newValue !== null) {
2762
+ this.mipmaps = parseBool(newValue, true);
2763
+ }
2764
+ else {
2765
+ this.mipmaps = null;
2766
+ }
2767
+ break;
2768
+ case 'srgb':
2769
+ if (newValue !== null) {
2770
+ this.srgb = parseBool(newValue, false);
2771
+ }
2772
+ else {
2773
+ this.srgb = null;
2774
+ }
2775
+ break;
2269
2776
  }
2270
2777
  }
2271
2778
  }
@@ -2281,6 +2788,16 @@ class ComponentElement extends AsyncElement {
2281
2788
  _enabled = true;
2282
2789
  _component = null;
2283
2790
  _appElement = null;
2791
+ /**
2792
+ * The element hosting this component, held so the host's readiness cycles can be observed
2793
+ * even after `closestEntity` would no longer resolve (during teardown).
2794
+ */
2795
+ _hostElement = null;
2796
+ /**
2797
+ * The listener re-applying this component when the host's readiness cycles. Held for
2798
+ * removal on disconnect.
2799
+ */
2800
+ _hostReadyListener = null;
2284
2801
  /**
2285
2802
  * Incremented on every connect and disconnect. connectedCallback captures the value on entry
2286
2803
  * and abandons itself wherever it resumes from an await if the value has moved on — so a
@@ -2308,6 +2825,37 @@ class ComponentElement extends AsyncElement {
2308
2825
  getInitialComponentData() {
2309
2826
  return {};
2310
2827
  }
2828
+ /**
2829
+ * Creates the component on the host's current entity, removing it first from a previous
2830
+ * entity that is still alive (a retargeted `<pc-node>` moves its decorations with it). When
2831
+ * the entity already has a component of this type — a glTF node arriving with its authored
2832
+ * `render` component, say — warns and leaves `component` null. The element-level warning is
2833
+ * load-bearing: the engine's own duplicate-addComponent warning is Debug-stripped from
2834
+ * production builds, which would otherwise leave a silent null.
2835
+ */
2836
+ _applyComponent() {
2837
+ const entity = this._hostElement?.entity ?? null;
2838
+ if (this._component && this._component.entity === entity) {
2839
+ return;
2840
+ }
2841
+ // A retarget leaves the previous component on a still-live entity - remove it so the
2842
+ // decoration follows the element, or vanishes with a dissolved binding. A destroyed
2843
+ // entity took its components with it.
2844
+ const previous = this._component;
2845
+ if (previous?.entity && previous.entity.c[this._componentName] === previous) {
2846
+ previous.entity.removeComponent(this._componentName);
2847
+ }
2848
+ this._component = null;
2849
+ if (!entity) {
2850
+ return;
2851
+ }
2852
+ if (entity.c[this._componentName]) {
2853
+ const label = this.id ? ` '${this.id}'` : '';
2854
+ console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
2855
+ return;
2856
+ }
2857
+ this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
2858
+ }
2311
2859
  async _addComponent() {
2312
2860
  const generation = this._connectionGeneration;
2313
2861
  const entityElement = this.closestEntity;
@@ -2324,9 +2872,40 @@ class ComponentElement extends AsyncElement {
2324
2872
  if (generation !== this._connectionGeneration) {
2325
2873
  return;
2326
2874
  }
2327
- // Add the component to the entity
2328
- const data = this.getInitialComponentData();
2329
- this._component = entityElement.entity.addComponent(this._componentName, data);
2875
+ this._hostElement = entityElement;
2876
+ this._applyComponent();
2877
+ // Re-apply when the host's readiness cycles without this element disconnecting: a
2878
+ // `<pc-node>` rebinding after its model reloads or retargets, or a re-created entity.
2879
+ // The 'ready' event bubbles, so events from descendants pass through this host - only
2880
+ // the host's own cycles count. Readiness is cycled here too, so decorations one level
2881
+ // down re-apply the same way.
2882
+ this._hostReadyListener = (event) => {
2883
+ if (event.target !== this._hostElement) {
2884
+ return;
2885
+ }
2886
+ if (generation !== this._connectionGeneration) {
2887
+ return;
2888
+ }
2889
+ this._hostCycled();
2890
+ };
2891
+ entityElement.addEventListener('ready', this._hostReadyListener);
2892
+ }
2893
+ /**
2894
+ * Re-evaluates this component against the host's current entity: applied to a new entity,
2895
+ * moved from a still-live old one, or removed when the host no longer fronts an entity at
2896
+ * all. Readiness follows - it cycles with a re-application and stays unresolved while the
2897
+ * host is unbound. Called by the host-ready listener, and directly by a `<pc-node>`
2898
+ * dissolving its binding: the one transition that fires no ready event to ride.
2899
+ *
2900
+ * @internal
2901
+ */
2902
+ _hostCycled() {
2903
+ this._resetReady();
2904
+ this._applyComponent();
2905
+ if (this._hostElement?.entity) {
2906
+ this.initComponent();
2907
+ this._onReady();
2908
+ }
2330
2909
  }
2331
2910
  /**
2332
2911
  * Configures the newly added component. Overridden by subclasses whose setup goes beyond
@@ -2355,6 +2934,11 @@ class ComponentElement extends AsyncElement {
2355
2934
  disconnectedCallback() {
2356
2935
  // Invalidate any connectedCallback still suspended on an await
2357
2936
  this._connectionGeneration++;
2937
+ if (this._hostElement && this._hostReadyListener) {
2938
+ this._hostElement.removeEventListener('ready', this._hostReadyListener);
2939
+ }
2940
+ this._hostElement = null;
2941
+ this._hostReadyListener = null;
2358
2942
  // Remove the component when the element is disconnected. Skip this when the owning
2359
2943
  // application has already been destroyed — removing a <pc-app> disconnects it before
2360
2944
  // its children, taking the component systems with it.
@@ -3314,6 +3898,12 @@ customElements.define('pc-camera', CameraComponentElement);
3314
3898
  * The CollisionComponentElement interface also inherits the properties and methods of the
3315
3899
  * {@link HTMLElement} interface.
3316
3900
  *
3901
+ * For `type="mesh"`, the collision geometry defaults to the host entity's own render component
3902
+ * (its render asset) — a collider matching the visible mesh, which is what a mesh collider on a
3903
+ * glTF node means. The default resolves each time the component applies, so a `pc-node` that
3904
+ * retargets or rebinds picks up the new node's geometry. An entity with no asset-backed render
3905
+ * component warns, and the collider has no shape.
3906
+ *
3317
3907
  * @category Components
3318
3908
  */
3319
3909
  class CollisionComponentElement extends ComponentElement {
@@ -3341,6 +3931,29 @@ class CollisionComponentElement extends ComponentElement {
3341
3931
  type: this._type
3342
3932
  };
3343
3933
  }
3934
+ initComponent() {
3935
+ this._applyMeshGeometryDefault();
3936
+ }
3937
+ /**
3938
+ * Defaults a mesh collider's geometry to the host entity's own render component. The
3939
+ * engine's mesh collider only works with explicitly supplied geometry, and the element has
3940
+ * no attribute to supply it - so the host's visible geometry, the meaning a mesh collider
3941
+ * on a glTF node carries, fills the gap. Runs on every application (so a rebound `pc-node`
3942
+ * recomputes it) and on a runtime switch to `type="mesh"`; an explicitly assigned
3943
+ * `renderAsset` is never overwritten.
3944
+ */
3945
+ _applyMeshGeometryDefault() {
3946
+ const component = this.component;
3947
+ if (!component || this._type !== 'mesh' || component.renderAsset !== null) {
3948
+ return;
3949
+ }
3950
+ const asset = component.entity.render?.asset ?? null;
3951
+ if (asset === null) {
3952
+ console.warn(`pc-collision type="mesh" on '${component.entity.name}' found no asset-backed render component to take geometry from - collider has no shape`);
3953
+ return;
3954
+ }
3955
+ component.renderAsset = asset;
3956
+ }
3344
3957
  /**
3345
3958
  * Gets the underlying PlayCanvas collision component.
3346
3959
  * @returns The collision component.
@@ -3415,6 +4028,7 @@ class CollisionComponentElement extends ComponentElement {
3415
4028
  this._type = value;
3416
4029
  if (this.component) {
3417
4030
  this.component.type = value;
4031
+ this._applyMeshGeometryDefault();
3418
4032
  }
3419
4033
  }
3420
4034
  get type() {
@@ -5585,13 +6199,13 @@ class MaterialElement extends HTMLElement {
5585
6199
  }
5586
6200
  /**
5587
6201
  * @param slot - The material property to write.
5588
- * @param texture - The loaded texture.
6202
+ * @param texture - The loaded texture, applied with its sampler state untouched - anisotropy
6203
+ * and friends belong to the `pc-asset`'s texture options.
5589
6204
  */
5590
6205
  _applyMap(slot, texture) {
5591
6206
  if (!this.material)
5592
6207
  return;
5593
6208
  this.material[slot] = texture;
5594
- texture.anisotropy = 4;
5595
6209
  this._scheduleUpdate();
5596
6210
  }
5597
6211
  /**
@@ -9871,6 +10485,21 @@ customElements.define('pc-gsplat', GSplatComponentElement);
9871
10485
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
9872
10486
  * The ModelElement interface also inherits the properties and methods of the
9873
10487
  * {@link HTMLElement} interface.
10488
+ *
10489
+ * The element becomes ready once its container asset has loaded and the instantiated hierarchy has
10490
+ * been added to the scene — `entity` is non-null by then. A failed load also settles readiness,
10491
+ * with `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen
10492
+ * for `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness
10493
+ * and instantiates anew, so a `ready()` obtained after the change resolves against the new
10494
+ * hierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never
10495
+ * becomes ready.
10496
+ *
10497
+ * @fires {Event} load - Fired each time a container asset finishes instantiating, including
10498
+ * re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a
10499
+ * capture-phase listener on an ancestor.
10500
+ * @fires {ErrorEvent} error - Fired when the container asset fails to load, with the engine's
10501
+ * error in `message`. Does not bubble. The element still becomes ready — readiness means the load
10502
+ * settled, not that it succeeded.
9874
10503
  */
9875
10504
  class ModelElement extends AsyncElement {
9876
10505
  _asset = '';
@@ -9883,11 +10512,12 @@ class ModelElement extends AsyncElement {
9883
10512
  */
9884
10513
  _loadGeneration = 0;
9885
10514
  /**
9886
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
9887
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
9888
- * than leave it registered until the asset loads (or forever, if it never does).
10515
+ * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
10516
+ * so that whatever supersedes the load can detach the handlers from the asset, rather than
10517
+ * leave them registered until the asset settles (or forever, if it never does).
9889
10518
  */
9890
10519
  _loadHandle = null;
10520
+ _errorHandle = null;
9891
10521
  /**
9892
10522
  * The root entity of the instantiated model. `null` until the container asset has loaded
9893
10523
  * and been instantiated, and again once the element has been removed from the document.
@@ -9897,18 +10527,36 @@ class ModelElement extends AsyncElement {
9897
10527
  return this._entity;
9898
10528
  }
9899
10529
  connectedCallback() {
10530
+ // A model outside an application is inert and never becomes ready, so awaiting it hangs.
10531
+ // Warn rather than fail silently, naming the parent it requires, as every other misplaced
10532
+ // element does.
10533
+ if (!this.closestApp) {
10534
+ const label = this._asset ? ` '${this._asset}'` : '';
10535
+ console.warn(`pc-model${label} must be a descendant of pc-app - model not created`);
10536
+ return;
10537
+ }
9900
10538
  this._loadModel();
9901
- this._onReady();
9902
10539
  }
9903
10540
  disconnectedCallback() {
9904
10541
  this._loadGeneration++;
9905
- this._detachLoadHandler();
10542
+ this._detachLoadHandlers();
9906
10543
  this._unloadModel();
9907
10544
  this._resetReady();
9908
10545
  }
9909
- _detachLoadHandler() {
10546
+ _detachLoadHandlers() {
9910
10547
  this._loadHandle?.off();
9911
10548
  this._loadHandle = null;
10549
+ this._errorHandle?.off();
10550
+ this._errorHandle = null;
10551
+ }
10552
+ /**
10553
+ * Resolves readiness and dispatches the `load` event. Called once the instantiated hierarchy
10554
+ * has been parented — readiness means "in the scene graph", matching `pc-entity`, so a ready
10555
+ * model's entity always has world transforms.
10556
+ */
10557
+ _announceLoad() {
10558
+ this._onReady();
10559
+ this.dispatchEvent(new Event('load'));
9912
10560
  }
9913
10561
  _instantiate(container) {
9914
10562
  const generation = this._loadGeneration;
@@ -9930,6 +10578,7 @@ class ModelElement extends AsyncElement {
9930
10578
  return;
9931
10579
  }
9932
10580
  parentEntityElement.entity.addChild(entity);
10581
+ this._announceLoad();
9933
10582
  });
9934
10583
  }
9935
10584
  else {
@@ -9940,6 +10589,7 @@ class ModelElement extends AsyncElement {
9940
10589
  return;
9941
10590
  }
9942
10591
  appElement.app.root.addChild(entity);
10592
+ this._announceLoad();
9943
10593
  });
9944
10594
  }
9945
10595
  }
@@ -9948,15 +10598,29 @@ class ModelElement extends AsyncElement {
9948
10598
  this._unloadModel();
9949
10599
  // Supersede any load already in flight - only the newest load may instantiate
9950
10600
  const generation = ++this._loadGeneration;
9951
- this._detachLoadHandler();
9952
- const appElement = await this.closestApp?.ready();
10601
+ this._detachLoadHandlers();
10602
+ // Re-arm readiness so a waiter obtained after an asset change resolves against the new
10603
+ // hierarchy. A no-op on first connection, where readiness is still pending.
10604
+ this._resetReady();
10605
+ const appElement = this.closestApp;
10606
+ if (!appElement) {
10607
+ // Outside pc-app; connectedCallback already warned. Reached through the asset setter.
10608
+ return;
10609
+ }
10610
+ await appElement.ready();
9953
10611
  // The element may have been removed, or another load started, while we waited
9954
10612
  if (generation !== this._loadGeneration) {
9955
10613
  return;
9956
10614
  }
9957
- const app = appElement?.app;
10615
+ const app = appElement.app;
9958
10616
  const asset = AssetElement.get(this._asset);
9959
10617
  if (!asset) {
10618
+ // An empty id is a legitimate transient (the asset may be assigned later); a
10619
+ // non-empty one that resolves to nothing is a dead end - say so rather than staying
10620
+ // silently pending.
10621
+ if (this._asset) {
10622
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
10623
+ }
9960
10624
  return;
9961
10625
  }
9962
10626
  if (asset.loaded) {
@@ -9965,14 +10629,26 @@ class ModelElement extends AsyncElement {
9965
10629
  else {
9966
10630
  // The generation is re-checked even though a superseded handler is detached: the
9967
10631
  // detach relies on how the engine's event emitter treats removal, while the check
9968
- // holds on its own.
10632
+ // holds on its own. Whichever of load/error fires first detaches the other.
9969
10633
  this._loadHandle = asset.once('load', () => {
9970
- this._loadHandle = null;
10634
+ this._detachLoadHandlers();
9971
10635
  if (generation !== this._loadGeneration) {
9972
10636
  return;
9973
10637
  }
9974
10638
  this._instantiate(asset.resource);
9975
10639
  });
10640
+ this._errorHandle = asset.once('error', (err) => {
10641
+ this._detachLoadHandlers();
10642
+ if (generation !== this._loadGeneration) {
10643
+ return;
10644
+ }
10645
+ // A failed load settles readiness with a null entity, mirroring pc-asset:
10646
+ // readiness means the load settled, not that it succeeded.
10647
+ this.dispatchEvent(new ErrorEvent('error', {
10648
+ message: err instanceof Error ? err.message : String(err)
10649
+ }));
10650
+ this._onReady();
10651
+ });
9976
10652
  app.assets.load(asset);
9977
10653
  }
9978
10654
  }
@@ -10010,6 +10686,625 @@ class ModelElement extends AsyncElement {
10010
10686
  }
10011
10687
  customElements.define('pc-model', ModelElement);
10012
10688
 
10689
+ /**
10690
+ * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
10691
+ * resolution warnings.
10692
+ *
10693
+ * @param a - The first string.
10694
+ * @param b - The second string.
10695
+ * @returns The edit distance.
10696
+ */
10697
+ const levenshtein = (a, b) => {
10698
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
10699
+ for (let i = 1; i <= a.length; i++) {
10700
+ let previous = row[0];
10701
+ row[0] = i;
10702
+ for (let j = 1; j <= b.length; j++) {
10703
+ const current = row[j];
10704
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
10705
+ previous = current;
10706
+ }
10707
+ }
10708
+ return row[b.length];
10709
+ };
10710
+ /**
10711
+ * The NodeElement interface provides properties and methods for manipulating
10712
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-node/ | `<pc-node>`}
10713
+ * elements. The NodeElement interface also inherits the properties and methods of the
10714
+ * {@link HTMLElement} interface.
10715
+ *
10716
+ * A `pc-node` is an override element: where `pc-entity` creates an entity, `pc-node` binds to a
10717
+ * node a `pc-model` loaded and declares overrides against the authored asset — components to
10718
+ * add, properties to change, content to attach. Attributes present apply as overrides; attributes
10719
+ * absent leave authored values untouched, and removing an attribute (or assigning `null` to the
10720
+ * matching property) restores the authored value.
10721
+ *
10722
+ * `name` selects among the host model's nodes (first match in depth-first order), nesting a
10723
+ * `pc-node` inside another scopes the search to that subtree, and `index` picks among identically
10724
+ * named matches. When `name` matches more than one node and no `index` is given, the element
10725
+ * warns and binds nothing.
10726
+ *
10727
+ * The element becomes ready once bound, and never while unresolved — a missing or ambiguous
10728
+ * name warns and records the failure in `state`, readiness stays unresolved, and descendants
10729
+ * wait with it.
10730
+ *
10731
+ * The pointer events below are dispatched by the containing `<pc-app>` element when the pointer
10732
+ * intersects the bound node's geometry, exactly as for `<pc-entity>`.
10733
+ *
10734
+ * @attribute {string} name - The name of the node to bind, resolved within the nearest ancestor
10735
+ * `pc-model` (or `pc-node`) once it has instantiated.
10736
+ * @attribute {number} index - Which match to bind when `name` matches more than one node,
10737
+ * 0-based in depth-first order. Optional for a unique match; required for an ambiguous one.
10738
+ * @attribute {boolean} enabled - Overrides the node's enabled state.
10739
+ * @attribute {string} position - Overrides the node's local position, as an "x y z" triple.
10740
+ * @attribute {string} rotation - Overrides the node's local rotation (Euler angles), as an
10741
+ * "x y z" triple.
10742
+ * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
10743
+ * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
10744
+ * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
10745
+ * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
10746
+ * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
10747
+ * @attribute {string} onpointerdown - Script to run when a pointer button is pressed over the
10748
+ * node.
10749
+ * @attribute {string} onpointerup - Script to run when a pointer button is released over the
10750
+ * node.
10751
+ * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the node.
10752
+ * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the node.
10753
+ * @fires {PointerEvent} pointermove - Fired when the pointer moves over the node.
10754
+ * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the node.
10755
+ * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the node.
10756
+ */
10757
+ class NodeElement extends EntityBaseElement {
10758
+ _name = '';
10759
+ _index = null;
10760
+ _state = 'pending';
10761
+ _path = null;
10762
+ /**
10763
+ * The element whose entity roots this element's search: the nearest ancestor `pc-node`, or
10764
+ * failing that the nearest ancestor `pc-model`. Resolved on connection.
10765
+ */
10766
+ _host = null;
10767
+ /**
10768
+ * The listener following the host's binding cycles. Both host kinds announce each cycle
10769
+ * with a `ready` event — `pc-model` on every instantiation, `pc-node` on every bind.
10770
+ */
10771
+ _hostListener = null;
10772
+ /**
10773
+ * The subscription to the bound entity's destruction, detached on unbind so a retargeted
10774
+ * element cannot be reset by the eventual death of a node it no longer fronts.
10775
+ */
10776
+ _destroyHandle = null;
10777
+ /** The authored values displaced by this element's overrides, captured per property. */
10778
+ _authored = {};
10779
+ // Override values. `null` means "no override": the authored value stays in force.
10780
+ _enabled = null;
10781
+ _position = null;
10782
+ _rotation = null;
10783
+ _scale = null;
10784
+ _tags = null;
10785
+ /**
10786
+ * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
10787
+ * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
10788
+ * warns). Useful for asserting a document's bindings programmatically.
10789
+ * @returns The binding state.
10790
+ */
10791
+ get state() {
10792
+ return this._state;
10793
+ }
10794
+ /**
10795
+ * The path of the bound node below the search root, `/`-separated, or `null` while not
10796
+ * bound.
10797
+ * @returns The bound node's path, or `null`.
10798
+ */
10799
+ get path() {
10800
+ return this._path;
10801
+ }
10802
+ connectedCallback() {
10803
+ const host = (this.parentElement?.closest('pc-model, pc-node') ?? null);
10804
+ if (!host) {
10805
+ const label = this._name ? ` '${this._name}'` : '';
10806
+ console.warn(`pc-node${label} must be a descendant of pc-model - node not bound`);
10807
+ return;
10808
+ }
10809
+ this._host = host;
10810
+ // Follow the host's binding cycles. `ready` bubbles, so cycles of elements nested under
10811
+ // the host pass through it - only the host's own count.
10812
+ this._hostListener = (event) => {
10813
+ if (event.target !== this._host) {
10814
+ return;
10815
+ }
10816
+ this._rebind();
10817
+ };
10818
+ host.addEventListener('ready', this._hostListener);
10819
+ // The host may already be instantiated (an element inserted after load binds immediately)
10820
+ this._rebind();
10821
+ }
10822
+ disconnectedCallback() {
10823
+ if (this._host && this._hostListener) {
10824
+ this._host.removeEventListener('ready', this._hostListener);
10825
+ }
10826
+ this._host = null;
10827
+ this._hostListener = null;
10828
+ // Removal reverts: the model owns the node, so the entity is left as authored. Children
10829
+ // clean up through their own disconnect behavior.
10830
+ this._unbind();
10831
+ this._state = 'pending';
10832
+ }
10833
+ /**
10834
+ * Re-resolves the binding against the host's current hierarchy: on connection, on a `name`
10835
+ * or `index` change, and on every host cycle (a model [re]instantiating, an enclosing
10836
+ * `pc-node` [re]binding). When re-resolution yields the entity already bound, the binding
10837
+ * is retained untouched — a redundant edit must not flicker overrides through a revert.
10838
+ */
10839
+ _rebind() {
10840
+ const hostEntity = this._host?.entity ?? null;
10841
+ if (!hostEntity || !this._name) {
10842
+ // Host not instantiated (or nothing to look up yet): return to pending. An assigned
10843
+ // name arriving later, or the host's next cycle, resolves it.
10844
+ this._unbind();
10845
+ this._state = 'pending';
10846
+ return;
10847
+ }
10848
+ const target = this._resolve(hostEntity);
10849
+ if (target && target === this._entity) {
10850
+ this._path = this._pathOf(target, hostEntity);
10851
+ return;
10852
+ }
10853
+ this._unbind();
10854
+ if (!target) {
10855
+ // _resolve warned and set the failure state
10856
+ return;
10857
+ }
10858
+ this._bind(target, hostEntity);
10859
+ }
10860
+ /**
10861
+ * Resolves `name` (and `index`) to an entity under `hostEntity`, warning and recording the
10862
+ * failure state when it cannot.
10863
+ *
10864
+ * @param hostEntity - The root of the search.
10865
+ * @returns The resolved entity, or `null`.
10866
+ */
10867
+ _resolve(hostEntity) {
10868
+ const matches = hostEntity.find((node) => node.name === this._name);
10869
+ if (matches.length === 0) {
10870
+ const closest = this._closestName(hostEntity);
10871
+ const hint = closest ? ` - closest match: '${closest}'` : '';
10872
+ console.warn(`pc-node '${this._name}' not found in ${this._describeHost()}${hint}`);
10873
+ this._state = 'missing';
10874
+ return null;
10875
+ }
10876
+ let target;
10877
+ if (this._index !== null) {
10878
+ if (this._index >= matches.length) {
10879
+ console.warn(`pc-node '${this._name}' index ${this._index} is out of range - ${matches.length} match(es) in ${this._describeHost()}`);
10880
+ this._state = 'missing';
10881
+ return null;
10882
+ }
10883
+ target = matches[this._index];
10884
+ }
10885
+ else if (matches.length > 1) {
10886
+ // Ambiguity binds nothing: a fallback guess performs side effects on the wrong
10887
+ // scene node, and would go wrong silently when a re-export introduces a duplicate
10888
+ // name. The candidates tell the author exactly what to write.
10889
+ const candidates = matches.map((m, i) => `[${i}] ${this._pathOf(m, hostEntity)}`).join(', ');
10890
+ console.warn(`pc-node '${this._name}' is ambiguous in ${this._describeHost()} - specify index: ${candidates}`);
10891
+ this._state = 'ambiguous';
10892
+ return null;
10893
+ }
10894
+ else {
10895
+ target = matches[0];
10896
+ }
10897
+ const owner = this.closestApp?.elementFromEntity(target);
10898
+ if (owner && owner !== this) {
10899
+ console.warn(`pc-node '${this._name}' resolves to a node already bound by another element - element ignored`);
10900
+ this._state = 'duplicate';
10901
+ return null;
10902
+ }
10903
+ return target;
10904
+ }
10905
+ /**
10906
+ * Binds `target`: registers it (making it a pick target), hooks its destruction, applies
10907
+ * this element's overrides, announces readiness and builds the deferred child subtree.
10908
+ *
10909
+ * @param target - The entity to bind.
10910
+ * @param hostEntity - The search root, for the path.
10911
+ */
10912
+ _bind(target, hostEntity) {
10913
+ this._entity = target;
10914
+ this._registerEntity(target);
10915
+ this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
10916
+ this._state = 'bound';
10917
+ this._path = this._pathOf(target, hostEntity);
10918
+ this._applyOverrides();
10919
+ this._onReady();
10920
+ this._buildChildren();
10921
+ }
10922
+ /**
10923
+ * Dissolves the current binding, restoring every authored value this element's overrides
10924
+ * displaced and removing the decorations this binding hosts: attachment entities are
10925
+ * destroyed (re-created against the next binding) and component decorations are removed
10926
+ * from the abandoned node. Both sweeps are scoped by `closestEntity`, so a still-bound
10927
+ * nested `pc-node` keeps its own decorations. Safe to call in any state.
10928
+ */
10929
+ _unbind() {
10930
+ const entity = this._entity;
10931
+ if (!entity) {
10932
+ return;
10933
+ }
10934
+ this._revertOverrides();
10935
+ // Attachment points anchor to the bound node, so they cannot outlive the binding. Each
10936
+ // destroyed entity resets its element, which the next _buildChildren re-creates.
10937
+ this.querySelectorAll('pc-entity').forEach((child) => {
10938
+ if (child.closestEntity === this) {
10939
+ child.entity?.destroy();
10940
+ }
10941
+ });
10942
+ this._destroyHandle?.off();
10943
+ this._destroyHandle = null;
10944
+ this._unregisterEntity(entity);
10945
+ this._entity = null;
10946
+ this._path = null;
10947
+ this._authored = {};
10948
+ // Component decorations come off through the same hook the host-ready cycle uses. A
10949
+ // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
10950
+ // `_entity` is cleared, so the hook sees a host without an entity.
10951
+ this.querySelectorAll('*').forEach((child) => {
10952
+ if (child instanceof ComponentElement && child.closestEntity === this) {
10953
+ child._hostCycled();
10954
+ }
10955
+ });
10956
+ this._resetReady();
10957
+ }
10958
+ /**
10959
+ * Handles the destruction of the bound entity - its model unloading, reloading, or a script
10960
+ * destroying it. There is nothing to revert on a destroyed entity; the element returns to
10961
+ * pending and the host's next cycle re-resolves it.
10962
+ */
10963
+ _onEntityDestroy(entity) {
10964
+ this._destroyHandle = null;
10965
+ this._unregisterEntity(entity);
10966
+ this._entity = null;
10967
+ this._path = null;
10968
+ this._authored = {};
10969
+ this._state = 'pending';
10970
+ this._resetReady();
10971
+ }
10972
+ /**
10973
+ * Creates and parents the entities of child `pc-entity` elements - the attachment points.
10974
+ * Mirrors the runtime-insertion path in EntityElement.connectedCallback: children were
10975
+ * deferred while this host was unresolved (or reset when a previous binding dissolved), and
10976
+ * build here once it binds.
10977
+ */
10978
+ _buildChildren() {
10979
+ const app = this.closestApp?.app;
10980
+ if (!app) {
10981
+ return;
10982
+ }
10983
+ const childEntities = this.querySelectorAll('pc-entity');
10984
+ childEntities.forEach((child) => {
10985
+ child._createEntity(app);
10986
+ });
10987
+ childEntities.forEach((child) => {
10988
+ child._buildHierarchy(app);
10989
+ });
10990
+ }
10991
+ /**
10992
+ * Applies every override that is explicitly set, capturing the authored value it displaces.
10993
+ */
10994
+ _applyOverrides() {
10995
+ if (this._enabled !== null) {
10996
+ this.enabled = this._enabled;
10997
+ }
10998
+ if (this._position !== null) {
10999
+ this.position = this._position;
11000
+ }
11001
+ if (this._rotation !== null) {
11002
+ this.rotation = this._rotation;
11003
+ }
11004
+ if (this._scale !== null) {
11005
+ this.scale = this._scale;
11006
+ }
11007
+ if (this._tags !== null) {
11008
+ this.tags = this._tags;
11009
+ }
11010
+ }
11011
+ /**
11012
+ * Restores every authored value this element's overrides displaced. The override values
11013
+ * themselves are kept - they re-apply on the next binding.
11014
+ */
11015
+ _revertOverrides() {
11016
+ const entity = this._entity;
11017
+ const authored = this._authored;
11018
+ if (authored.enabled !== undefined) {
11019
+ entity.enabled = authored.enabled;
11020
+ }
11021
+ if (authored.position) {
11022
+ entity.setLocalPosition(authored.position);
11023
+ }
11024
+ if (authored.rotation) {
11025
+ entity.setLocalRotation(authored.rotation);
11026
+ }
11027
+ if (authored.scale) {
11028
+ entity.setLocalScale(authored.scale);
11029
+ }
11030
+ if (authored.tags) {
11031
+ entity.tags.clear();
11032
+ entity.tags.add(authored.tags);
11033
+ }
11034
+ this._authored = {};
11035
+ }
11036
+ /**
11037
+ * Renders the path of `node` below `root`, for the `path` property and the resolution
11038
+ * warnings.
11039
+ *
11040
+ * @param node - The node to describe.
11041
+ * @param root - The search root.
11042
+ * @returns The `/`-separated path.
11043
+ */
11044
+ _pathOf(node, root) {
11045
+ const parts = [];
11046
+ for (let current = node; current && current !== root; current = current.parent) {
11047
+ parts.unshift(current.name);
11048
+ }
11049
+ return parts.join('/') || node.name;
11050
+ }
11051
+ /**
11052
+ * Describes the search root for warnings: the model's asset id, or the enclosing node's
11053
+ * name.
11054
+ * @returns The description.
11055
+ */
11056
+ _describeHost() {
11057
+ if (this._host instanceof ModelElement) {
11058
+ return `model '${this._host.asset}'`;
11059
+ }
11060
+ return `pc-node '${this._host?.name ?? ''}' subtree`;
11061
+ }
11062
+ /**
11063
+ * Finds the node name nearest to the missing `name`, for the miss warning. The names are
11064
+ * already in hand from resolution, so the suggestion is nearly free.
11065
+ *
11066
+ * @param hostEntity - The root of the search.
11067
+ * @returns The closest name within an edit distance of 2, or `null`.
11068
+ */
11069
+ _closestName(hostEntity) {
11070
+ let best = null;
11071
+ let bestDistance = 3;
11072
+ hostEntity.find((node) => {
11073
+ const distance = levenshtein(this._name, node.name);
11074
+ if (distance < bestDistance) {
11075
+ bestDistance = distance;
11076
+ best = node.name;
11077
+ }
11078
+ return false;
11079
+ });
11080
+ return best;
11081
+ }
11082
+ /**
11083
+ * Sets the name of the node to bind. A change retargets: the current binding's overrides
11084
+ * revert and the new name resolves afresh. `name` on a `pc-node` is never a rename of the
11085
+ * authored node - it is only ever a reference.
11086
+ * @param value - The node name.
11087
+ */
11088
+ set name(value) {
11089
+ this._name = value;
11090
+ if (this.isConnected && this._host) {
11091
+ this._rebind();
11092
+ }
11093
+ }
11094
+ /**
11095
+ * Gets the name of the node to bind.
11096
+ * @returns The node name.
11097
+ */
11098
+ get name() {
11099
+ return this._name;
11100
+ }
11101
+ /**
11102
+ * Sets which match to bind when `name` matches more than one node, 0-based in depth-first
11103
+ * order. A change retargets, like `name`. `null` means unset - required when the name is
11104
+ * ambiguous, optional otherwise.
11105
+ * @param value - The match index, or `null`.
11106
+ */
11107
+ set index(value) {
11108
+ this._index = value;
11109
+ if (this.isConnected && this._host) {
11110
+ this._rebind();
11111
+ }
11112
+ }
11113
+ /**
11114
+ * Gets which match to bind.
11115
+ * @returns The match index, or `null` when unset.
11116
+ */
11117
+ get index() {
11118
+ return this._index;
11119
+ }
11120
+ /**
11121
+ * Sets the enabled override. `null` clears it, restoring the authored state.
11122
+ * @param value - The enabled state, or `null`.
11123
+ */
11124
+ set enabled(value) {
11125
+ this._enabled = value;
11126
+ const entity = this._state === 'bound' ? this._entity : null;
11127
+ if (!entity) {
11128
+ return;
11129
+ }
11130
+ if (value !== null) {
11131
+ this._authored.enabled ??= entity.enabled;
11132
+ entity.enabled = value;
11133
+ }
11134
+ else if (this._authored.enabled !== undefined) {
11135
+ entity.enabled = this._authored.enabled;
11136
+ delete this._authored.enabled;
11137
+ }
11138
+ }
11139
+ /**
11140
+ * Gets the enabled override.
11141
+ * @returns The enabled state, or `null` while no override is set.
11142
+ */
11143
+ get enabled() {
11144
+ return this._enabled;
11145
+ }
11146
+ /**
11147
+ * Sets the local position override. `null` clears it, restoring the authored position.
11148
+ * @param value - The position, or `null`.
11149
+ */
11150
+ set position(value) {
11151
+ this._position = value;
11152
+ const entity = this._state === 'bound' ? this._entity : null;
11153
+ if (!entity) {
11154
+ return;
11155
+ }
11156
+ if (value !== null) {
11157
+ this._authored.position ??= entity.getLocalPosition().clone();
11158
+ entity.setLocalPosition(value);
11159
+ }
11160
+ else if (this._authored.position) {
11161
+ entity.setLocalPosition(this._authored.position);
11162
+ delete this._authored.position;
11163
+ }
11164
+ }
11165
+ /**
11166
+ * Gets the local position override.
11167
+ * @returns The position, or `null` while no override is set.
11168
+ */
11169
+ get position() {
11170
+ return this._position;
11171
+ }
11172
+ /**
11173
+ * Sets the local rotation override, as Euler angles in degrees. `null` clears it, restoring
11174
+ * the authored rotation.
11175
+ * @param value - The rotation, or `null`.
11176
+ */
11177
+ set rotation(value) {
11178
+ this._rotation = value;
11179
+ const entity = this._state === 'bound' ? this._entity : null;
11180
+ if (!entity) {
11181
+ return;
11182
+ }
11183
+ if (value !== null) {
11184
+ // The authored rotation is cached as a quaternion: it restores exactly, where a
11185
+ // round trip through Euler angles need not.
11186
+ this._authored.rotation ??= entity.getLocalRotation().clone();
11187
+ entity.setLocalEulerAngles(value);
11188
+ }
11189
+ else if (this._authored.rotation) {
11190
+ entity.setLocalRotation(this._authored.rotation);
11191
+ delete this._authored.rotation;
11192
+ }
11193
+ }
11194
+ /**
11195
+ * Gets the local rotation override.
11196
+ * @returns The rotation, or `null` while no override is set.
11197
+ */
11198
+ get rotation() {
11199
+ return this._rotation;
11200
+ }
11201
+ /**
11202
+ * Sets the local scale override. `null` clears it, restoring the authored scale.
11203
+ * @param value - The scale, or `null`.
11204
+ */
11205
+ set scale(value) {
11206
+ this._scale = value;
11207
+ const entity = this._state === 'bound' ? this._entity : null;
11208
+ if (!entity) {
11209
+ return;
11210
+ }
11211
+ if (value !== null) {
11212
+ this._authored.scale ??= entity.getLocalScale().clone();
11213
+ entity.setLocalScale(value);
11214
+ }
11215
+ else if (this._authored.scale) {
11216
+ entity.setLocalScale(this._authored.scale);
11217
+ delete this._authored.scale;
11218
+ }
11219
+ }
11220
+ /**
11221
+ * Gets the local scale override.
11222
+ * @returns The scale, or `null` while no override is set.
11223
+ */
11224
+ get scale() {
11225
+ return this._scale;
11226
+ }
11227
+ /**
11228
+ * Sets the tags override. `null` clears it, restoring the authored tags.
11229
+ * @param value - The tags, or `null`.
11230
+ */
11231
+ set tags(value) {
11232
+ this._tags = value;
11233
+ const entity = this._state === 'bound' ? this._entity : null;
11234
+ if (!entity) {
11235
+ return;
11236
+ }
11237
+ if (value !== null) {
11238
+ this._authored.tags ??= entity.tags.list().slice();
11239
+ entity.tags.clear();
11240
+ entity.tags.add(value);
11241
+ }
11242
+ else if (this._authored.tags) {
11243
+ entity.tags.clear();
11244
+ entity.tags.add(this._authored.tags);
11245
+ delete this._authored.tags;
11246
+ }
11247
+ }
11248
+ /**
11249
+ * Gets the tags override.
11250
+ * @returns The tags, or `null` while no override is set.
11251
+ */
11252
+ get tags() {
11253
+ return this._tags;
11254
+ }
11255
+ static get observedAttributes() {
11256
+ return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
11257
+ }
11258
+ attributeChangedCallback(name, _oldValue, newValue) {
11259
+ switch (name) {
11260
+ case 'enabled':
11261
+ this.enabled = newValue === null ? null : parseBool(newValue, true);
11262
+ break;
11263
+ case 'index':
11264
+ if (newValue === null) {
11265
+ this.index = null;
11266
+ }
11267
+ else {
11268
+ // Number('') is 0, which would make index="" silently mean the first match
11269
+ const index = newValue.trim() === '' ? NaN : Number(newValue);
11270
+ if (!Number.isInteger(index) || index < 0) {
11271
+ // Invalid values are treated as absent: under ambiguity that means
11272
+ // unbound, the fail-safe direction.
11273
+ console.warn(`pc-node index '${newValue}' is not a non-negative integer - treated as absent`);
11274
+ this.index = null;
11275
+ }
11276
+ else {
11277
+ this.index = index;
11278
+ }
11279
+ }
11280
+ break;
11281
+ case 'name':
11282
+ this.name = newValue ?? '';
11283
+ break;
11284
+ case 'position':
11285
+ this.position = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ZERO, name);
11286
+ break;
11287
+ case 'rotation':
11288
+ this.rotation = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ZERO, name);
11289
+ break;
11290
+ case 'scale':
11291
+ this.scale = newValue === null ? null : parseVec3(newValue, playcanvas.Vec3.ONE, name);
11292
+ break;
11293
+ case 'tags':
11294
+ this.tags = newValue === null ? null : parseTags(newValue);
11295
+ break;
11296
+ case 'onpointerenter':
11297
+ case 'onpointerleave':
11298
+ case 'onpointerdown':
11299
+ case 'onpointerup':
11300
+ case 'onpointermove':
11301
+ this._updateInlineHandler(name, newValue);
11302
+ break;
11303
+ }
11304
+ }
11305
+ }
11306
+ customElements.define('pc-node', NodeElement);
11307
+
10013
11308
  /**
10014
11309
  * The SceneElement interface provides properties and methods for manipulating
10015
11310
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-scene/ | `<pc-scene>`} elements.
@@ -10282,7 +11577,6 @@ class SkyElement extends AsyncElement {
10282
11577
  return;
10283
11578
  const source = asset.resource;
10284
11579
  const skybox = playcanvas.EnvLighting.generateSkyboxCubemap(source);
10285
- skybox.anisotropy = 4;
10286
11580
  // This element owns what it generated (see _unloadSkybox) - replacing a skybox from an
10287
11581
  // earlier load must release it, not orphan it on the GPU
10288
11582
  this._scene.skybox?.destroy();
@@ -10539,6 +11833,7 @@ exports.CameraComponentElement = CameraComponentElement;
10539
11833
  exports.CollisionComponentElement = CollisionComponentElement;
10540
11834
  exports.ComponentElement = ComponentElement;
10541
11835
  exports.ElementComponentElement = ElementComponentElement;
11836
+ exports.EntityBaseElement = EntityBaseElement;
10542
11837
  exports.EntityElement = EntityElement;
10543
11838
  exports.GSplatComponentElement = GSplatComponentElement;
10544
11839
  exports.LayoutChildComponentElement = LayoutChildComponentElement;
@@ -10548,6 +11843,7 @@ exports.ListenerComponentElement = ListenerComponentElement;
10548
11843
  exports.MaterialElement = MaterialElement;
10549
11844
  exports.ModelElement = ModelElement;
10550
11845
  exports.ModuleElement = ModuleElement;
11846
+ exports.NodeElement = NodeElement;
10551
11847
  exports.ParticleSystemComponentElement = ParticleSystemComponentElement;
10552
11848
  exports.RenderComponentElement = RenderComponentElement;
10553
11849
  exports.RigidBodyComponentElement = RigidBodyComponentElement;