@playcanvas/web-components 0.11.0 → 0.12.0

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