@playcanvas/web-components 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +102 -51
  3. package/dist/asset.d.ts +8 -7
  4. package/dist/async-element.d.ts +21 -5
  5. package/dist/components/button-component.d.ts +3 -7
  6. package/dist/components/camera-component.d.ts +16 -20
  7. package/dist/components/collision-component.d.ts +3 -7
  8. package/dist/components/component.d.ts +22 -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 +28 -11
  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 +9 -11
  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 +5191 -10695
  26. package/dist/entity.d.ts +16 -9
  27. package/dist/index.d.ts +37 -0
  28. package/dist/material.d.ts +12 -12
  29. package/dist/model.d.ts +21 -5
  30. package/dist/module.d.ts +0 -6
  31. package/dist/parse.d.ts +6 -3
  32. package/dist/pwc.cjs +799 -287
  33. package/dist/pwc.cjs.map +1 -1
  34. package/dist/pwc.js +799 -287
  35. package/dist/pwc.js.map +1 -1
  36. package/dist/pwc.min.js +1 -1
  37. package/dist/pwc.min.js.map +1 -1
  38. package/dist/pwc.min.mjs +1 -1
  39. package/dist/pwc.min.mjs.map +1 -1
  40. package/dist/pwc.mjs +800 -288
  41. package/dist/pwc.mjs.map +1 -1
  42. package/dist/scene.d.ts +4 -7
  43. package/dist/sky.d.ts +25 -16
  44. package/dist/vscode.html-custom-data.json +65 -45
  45. package/dist/web-types.json +585 -557
  46. package/package.json +8 -7
  47. package/src/app.ts +326 -144
  48. package/src/asset.ts +34 -28
  49. package/src/async-element.ts +34 -8
  50. package/src/components/button-component.ts +5 -9
  51. package/src/components/camera-component.ts +55 -36
  52. package/src/components/collision-component.ts +26 -15
  53. package/src/components/component.ts +58 -8
  54. package/src/components/element-component.ts +26 -30
  55. package/src/components/gsplat-component.ts +4 -9
  56. package/src/components/layoutchild-component.ts +4 -9
  57. package/src/components/layoutgroup-component.ts +14 -9
  58. package/src/components/light-component.ts +42 -12
  59. package/src/components/listener-component.ts +1 -7
  60. package/src/components/particlesystem-component.ts +7 -15
  61. package/src/components/render-component.ts +5 -10
  62. package/src/components/rigidbody-component.ts +23 -16
  63. package/src/components/screen-component.ts +46 -20
  64. package/src/components/script-component.ts +108 -46
  65. package/src/components/script.ts +38 -33
  66. package/src/components/scrollbar-component.ts +6 -16
  67. package/src/components/scrollview-component.ts +22 -15
  68. package/src/components/sound-component.ts +10 -15
  69. package/src/components/sound-slot.ts +30 -20
  70. package/src/entity.ts +75 -34
  71. package/src/index.ts +45 -1
  72. package/src/loading-bar.ts +8 -8
  73. package/src/material.ts +63 -37
  74. package/src/model.ts +69 -14
  75. package/src/module.ts +8 -7
  76. package/src/parse.ts +67 -20
  77. package/src/scene.ts +12 -9
  78. package/src/sky.ts +76 -34
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, PROJECTION_ORTHOGRAPHIC, PROJECTION_PERSPECTIVE, GAMMA_SRGB, GAMMA_NONE, XRTYPE_VR, TONEMAP_NONE, TONEMAP_LINEAR, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_NEUTRAL, 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_BLEND, SCALEMODE_NONE, 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, 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';
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();
@@ -35,15 +37,39 @@ class AsyncElement extends HTMLElement {
35
37
  /**
36
38
  * Called when the element is fully initialized and ready. Subclasses should call this when
37
39
  * they're ready. Resolves the ready promise and dispatches a bubbling, composed `ready`
38
- * event.
40
+ * event. Signals at most once per readiness cycle: a repeat call before {@link _resetReady}
41
+ * has re-armed the promise does nothing.
39
42
  */
40
43
  _onReady() {
44
+ if (this._readyResolved)
45
+ return;
46
+ this._readyResolved = true;
41
47
  this._readyResolve();
42
48
  this.dispatchEvent(new CustomEvent('ready', { bubbles: true, composed: true }));
43
49
  }
50
+ /**
51
+ * Returns the ready promise to its pending state. Subclasses should call this when the
52
+ * resource their readiness announced is torn down (typically from `disconnectedCallback`),
53
+ * so that a later re-initialization can signal readiness again. Does nothing while the
54
+ * promise is still pending — an in-flight waiter carries over to the next readiness cycle
55
+ * rather than being stranded on a promise nothing will ever resolve.
56
+ */
57
+ _resetReady() {
58
+ if (!this._readyResolved)
59
+ return;
60
+ this._readyResolved = false;
61
+ this._readyPromise = new Promise((resolve) => {
62
+ this._readyResolve = resolve;
63
+ });
64
+ }
44
65
  /**
45
66
  * Returns a promise that resolves with this element when it's ready. This is the low-level
46
67
  * primitive underlying {@link whenReady}, which is the recommended way to wait for elements.
68
+ *
69
+ * Readiness tracks the element's current lifecycle: once a ready element is torn down (for
70
+ * example by removing it from the document), this returns a fresh promise that resolves when
71
+ * the element is next ready. A promise obtained earlier stays resolved — call this again
72
+ * after re-inserting an element rather than reusing a promise from before its removal.
47
73
  * @returns A promise that resolves with this element when it's ready.
48
74
  */
49
75
  ready() {
@@ -118,7 +144,14 @@ class ModuleElement extends HTMLElement {
118
144
  });
119
145
  }
120
146
  }
121
- getLoadPromise() {
147
+ /**
148
+ * Returns the promise that settles when the module has loaded. Awaited by the containing
149
+ * `<pc-app>` element before it creates its graphics device.
150
+ *
151
+ * @returns The load promise.
152
+ * @internal
153
+ */
154
+ _getLoadPromise() {
122
155
  return this.loadPromise;
123
156
  }
124
157
  }
@@ -177,10 +210,7 @@ class LoadingBar {
177
210
  // aria-valuenow is set, which is what marks a progressbar indeterminate. jsdom has no Web
178
211
  // Animations API, so the guard degrades to a static bar there rather than crashing boot.
179
212
  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
- ], {
213
+ this._sweep = this._fill.animate([{ transform: 'scaleX(0.25) translateX(-100%)' }, { transform: 'scaleX(0.25) translateX(500%)' }], {
184
214
  duration: 1000,
185
215
  iterations: Infinity,
186
216
  easing: 'ease-in-out'
@@ -413,7 +443,7 @@ const CSS_COLORS = {
413
443
  */
414
444
  const parseComponents = (value, count) => {
415
445
  const components = value.trim().split(/\s+/).map(Number);
416
- if (components.length !== count || components.some(component => !Number.isFinite(component))) {
446
+ if (components.length !== count || components.some((component) => !Number.isFinite(component))) {
417
447
  return null;
418
448
  }
419
449
  return components;
@@ -469,7 +499,10 @@ const parseColor = (value, defaultValue, attribute) => {
469
499
  if (/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) {
470
500
  let hex = value.slice(1);
471
501
  if (hex.length === 3 || hex.length === 4) {
472
- hex = hex.split('').map(char => char + char).join('');
502
+ hex = hex
503
+ .split('')
504
+ .map((char) => char + char)
505
+ .join('');
473
506
  }
474
507
  return new Color().fromString(`#${hex}`);
475
508
  }
@@ -487,7 +520,9 @@ const parseColor = (value, defaultValue, attribute) => {
487
520
  * the value is invalid — the latter also logs a warning listing the valid names.
488
521
  *
489
522
  * @param value - The attribute value to parse (`null` when the attribute is absent).
490
- * @param valid - The valid names: an array, or a map whose keys are the valid names.
523
+ * @param valid - The valid names: an array, or a map whose keys are the valid names. Only the keys
524
+ * are read, so the map's value type is unconstrained - engine enums are mostly numeric constants,
525
+ * but some (e.g. `SCALEMODE_BLEND`) are strings.
491
526
  * @param defaultValue - The value to use when the attribute is absent or invalid.
492
527
  * @param attribute - The attribute name, used in the warning message.
493
528
  * @returns The resolved enum name.
@@ -564,7 +599,10 @@ const parseTags = (value, defaultValue = []) => {
564
599
  // caller's default, or a later mutation would write back through it.
565
600
  return [...defaultValue];
566
601
  }
567
- return value.split(',').map(tag => tag.trim()).filter(tag => tag !== '');
602
+ return value
603
+ .split(',')
604
+ .map((tag) => tag.trim())
605
+ .filter((tag) => tag !== '');
568
606
  };
569
607
  /**
570
608
  * Parse a Vec2 attribute value. The expected format is 2 space-separated numbers (e.g. '1 2').
@@ -656,6 +694,8 @@ const getEntity = (ref) => {
656
694
  return element?.entity ?? null;
657
695
  };
658
696
 
697
+ /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
698
+ const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'];
659
699
  /**
660
700
  * The AppElement interface provides properties and methods for manipulating
661
701
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
@@ -675,12 +715,37 @@ class AppElement extends AsyncElement {
675
715
  _alpha = true;
676
716
  _backend = 'webgpu';
677
717
  _antialias = true;
678
- _depth = true;
679
- _stencil = true;
680
- _highResolution = true;
718
+ _depthBuffer = true;
719
+ _stencilBuffer = true;
720
+ _maxPixelRatio = Infinity;
681
721
  _loadingBar = true;
722
+ /**
723
+ * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
724
+ * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
725
+ * cleared on disconnect so a re-connected element boots from its current attributes.
726
+ */
727
+ _optionsLocked = false;
682
728
  _bar = null;
729
+ /**
730
+ * Whether the application has created its initial entity hierarchy. Read by EntityElement to
731
+ * decide whether a newly connected element must create its entity itself or leave it to the
732
+ * boot sweep.
733
+ * @internal
734
+ */
683
735
  _hierarchyReady = false;
736
+ /**
737
+ * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
738
+ * itself wherever it resumes from an await if the value has moved on — so a boot whose
739
+ * element was removed cannot complete against a torn-down element, and a boot whose element
740
+ * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
741
+ */
742
+ _bootGeneration = 0;
743
+ /**
744
+ * The elements backing this application's entities, keyed by the entity itself. Registered
745
+ * by EntityElement at creation and removed when an entity is destroyed, this joins engine
746
+ * scene nodes back to their owning elements by identity - never by name.
747
+ */
748
+ _entityElements = new Map();
684
749
  _picker = null;
685
750
  _hasPointerListeners = {
686
751
  pointerenter: false,
@@ -727,8 +792,16 @@ class AppElement extends AsyncElement {
727
792
  super();
728
793
  // Bind methods to maintain 'this' context
729
794
  this._onWindowResize = this._onWindowResize.bind(this);
795
+ // Track pointer listeners being added to and removed from descendant entities.
796
+ // Registered once here rather than on every boot - the handlers no-op while there is no
797
+ // canvas, and a re-booted element must not stack a second set.
798
+ pointerEventTypes.forEach((type) => {
799
+ this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
800
+ this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
801
+ });
730
802
  }
731
803
  async connectedCallback() {
804
+ const generation = ++this._bootGeneration;
732
805
  // Created before the first await, so the bar is visible while modules and the graphics
733
806
  // device are created, and exists before any disconnect could need to clean it up
734
807
  if (this._loadingBar && !this._bar) {
@@ -737,7 +810,12 @@ class AppElement extends AsyncElement {
737
810
  // Get all pc-module elements that are direct children of the pc-app element
738
811
  const moduleElements = this.querySelectorAll(':scope > pc-module');
739
812
  // Wait for all modules to load
740
- await Promise.all(Array.from(moduleElements).map(module => module.getLoadPromise()));
813
+ await Promise.all(Array.from(moduleElements).map((module) => module._getLoadPromise()));
814
+ // The element may have been removed while the modules loaded. Nothing beyond the loading
815
+ // bar exists yet, and disconnectedCallback has already destroyed that.
816
+ if (generation !== this._bootGeneration) {
817
+ return;
818
+ }
741
819
  // Create and append the canvas to the element
742
820
  this._canvas = document.createElement('canvas');
743
821
  this.appendChild(this._canvas);
@@ -748,15 +826,26 @@ class AppElement extends AsyncElement {
748
826
  null: ['null']
749
827
  };
750
828
  const deviceTypes = backendToDeviceTypes[this._backend] || [];
829
+ this._optionsLocked = true;
751
830
  const device = await createGraphicsDevice(this._canvas, {
752
831
  // @ts-ignore - alpha needs to be documented
753
832
  alpha: this._alpha,
754
833
  antialias: this._antialias,
755
- depth: this._depth,
834
+ depth: this._depthBuffer,
756
835
  deviceTypes: deviceTypes,
757
- stencil: this._stencil
836
+ stencil: this._stencilBuffer
758
837
  });
759
- device.maxPixelRatio = this._highResolution ? window.devicePixelRatio : 1;
838
+ // The element may have been removed while the device was created. disconnectedCallback
839
+ // has already cleaned up the canvas; the device was created inside the await, so it is
840
+ // this boot's to release.
841
+ if (generation !== this._bootGeneration) {
842
+ device.destroy();
843
+ return;
844
+ }
845
+ // Assigned rather than resolved to a number here: the engine caps against the live
846
+ // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
847
+ // display when a window moves between monitors of differing density.
848
+ device.maxPixelRatio = this._maxPixelRatio;
760
849
  const createOptions = new AppOptions();
761
850
  createOptions.graphicsDevice = device;
762
851
  createOptions.keyboard = new Keyboard(window);
@@ -829,27 +918,41 @@ class AppElement extends AsyncElement {
829
918
  this._pickerCreate();
830
919
  // Get all pc-asset elements that are direct children of the pc-app element
831
920
  const assetElements = this.querySelectorAll(':scope > pc-asset');
832
- Array.from(assetElements).forEach((assetElement) => {
833
- assetElement.createAsset();
921
+ for (const assetElement of Array.from(assetElements)) {
922
+ assetElement._createAsset();
834
923
  const asset = assetElement.asset;
835
924
  if (asset) {
836
925
  app.assets.add(asset);
926
+ // Adding a fileless asset (one built purely from data, such as a sprite)
927
+ // completes it synchronously, dispatching the element's load event - whose
928
+ // listeners may have removed this element. Stop before the next addition
929
+ // reaches the destroyed registry, and before orphan entities are created.
930
+ if (generation !== this._bootGeneration) {
931
+ return;
932
+ }
837
933
  }
838
- });
934
+ }
839
935
  // Get all pc-material elements that are direct children of the pc-app element
840
936
  const materialElements = this.querySelectorAll(':scope > pc-material');
841
937
  Array.from(materialElements).forEach((materialElement) => {
842
- materialElement.createMaterial();
938
+ materialElement._createMaterial();
843
939
  });
844
940
  // Create all entities
845
941
  const entityElements = this.querySelectorAll('pc-entity');
846
942
  Array.from(entityElements).forEach((entityElement) => {
847
- entityElement.createEntity(app);
943
+ entityElement._createEntity(app);
848
944
  });
849
945
  // Build hierarchy
850
946
  entityElements.forEach((entityElement) => {
851
- entityElement.buildHierarchy(app);
947
+ entityElement._buildHierarchy(app);
852
948
  });
949
+ // Building the hierarchy dispatched each entity's ready event synchronously, and a
950
+ // listener may have removed the element. The sweep itself degrades safely - destroying
951
+ // the application nulls every element's entity, so the remaining builds no-op - but the
952
+ // teardown's reset must not be overwritten here.
953
+ if (generation !== this._bootGeneration) {
954
+ return;
955
+ }
853
956
  this._hierarchyReady = true;
854
957
  // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
855
958
  // listener must be attached before preload() is called: an asset that is already loaded
@@ -866,8 +969,19 @@ class AppElement extends AsyncElement {
866
969
  this._loadProgress = total === 0 ? 1 : 0;
867
970
  this._bar?.progress(0, total);
868
971
  this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
972
+ // The progress dispatch above ran listeners synchronously, and one may have removed the
973
+ // element. The application is already destroyed - it must not be asked to preload.
974
+ if (generation !== this._bootGeneration) {
975
+ return;
976
+ }
869
977
  // Load assets before starting the application
870
978
  app.preload(() => {
979
+ // The element may have been removed while assets loaded. The application is already
980
+ // destroyed, so it must not be started — and readiness must not be signaled for a
981
+ // boot that no longer owns the element.
982
+ if (generation !== this._bootGeneration) {
983
+ return;
984
+ }
871
985
  // Scope the counter to this preload pass, so a later app.preload() call by user code
872
986
  // cannot push `loaded` past `total`
873
987
  app.off('preload:progress', onPreloadProgress);
@@ -883,15 +997,26 @@ class AppElement extends AsyncElement {
883
997
  });
884
998
  }
885
999
  disconnectedCallback() {
1000
+ // Invalidate any boot still in flight, so it abandons itself when it next resumes
1001
+ // instead of completing against a torn-down element.
1002
+ this._bootGeneration++;
1003
+ this._optionsLocked = false;
886
1004
  this._pickerDestroy();
887
- // Clean up the application
1005
+ // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1006
+ // unregister them - clear() covers any entity the engine no longer reached.
888
1007
  if (this._app) {
889
1008
  this._app.destroy();
890
1009
  this._app = null;
891
1010
  }
1011
+ this._entityElements.clear();
892
1012
  this._loadProgress = 0;
893
1013
  this._bar?.destroy();
894
1014
  this._bar = null;
1015
+ // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1016
+ // must neither see a hierarchy that no longer exists nor resume against a readiness that
1017
+ // no longer holds.
1018
+ this._hierarchyReady = false;
1019
+ this._resetReady();
895
1020
  // Remove event listeners
896
1021
  window.removeEventListener('resize', this._onWindowResize);
897
1022
  // Remove the canvas
@@ -919,14 +1044,11 @@ class AppElement extends AsyncElement {
919
1044
  this._pointerHandlers.pointermove = listener(this._onPointerMove);
920
1045
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
921
1046
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
922
- // Listen for pointer listeners being added/removed
923
- ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
924
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
925
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
926
- // Attach canvas handlers for listeners registered before this point (e.g. handlers
927
- // created from onpointer* attributes when their elements were first upgraded)
928
- const anyListeners = Array.from(this.querySelectorAll('pc-entity'))
929
- .some(entity => entity.hasListeners(type));
1047
+ // Attach canvas handlers for listeners registered before this boot (e.g. handlers
1048
+ // created from onpointer* attributes when their elements were first upgraded, or
1049
+ // listeners carried over from before a re-boot)
1050
+ pointerEventTypes.forEach((type) => {
1051
+ const anyListeners = Array.from(this.querySelectorAll('pc-entity')).some((entity) => entity._hasListeners(type));
930
1052
  if (anyListeners) {
931
1053
  this._onPointerListenerAdded(type);
932
1054
  }
@@ -941,6 +1063,7 @@ class AppElement extends AsyncElement {
941
1063
  });
942
1064
  }
943
1065
  this._picker = null;
1066
+ this._hoveredEntity = null;
944
1067
  this._pointerHandlers = {
945
1068
  pointermove: null,
946
1069
  pointerdown: null,
@@ -954,6 +1077,74 @@ class AppElement extends AsyncElement {
954
1077
  pointermove: false
955
1078
  };
956
1079
  }
1080
+ /**
1081
+ * Registers the element that created an entity. Called by EntityElement when it creates its
1082
+ * entity.
1083
+ *
1084
+ * @param entity - The entity.
1085
+ * @param element - The element that created it.
1086
+ * @internal
1087
+ */
1088
+ _registerEntityElement(entity, element) {
1089
+ this._entityElements.set(entity, element);
1090
+ }
1091
+ /**
1092
+ * Removes the registration for a destroyed entity. Called by EntityElement.
1093
+ *
1094
+ * @param entity - The entity.
1095
+ * @internal
1096
+ */
1097
+ _unregisterEntityElement(entity) {
1098
+ this._entityElements.delete(entity);
1099
+ }
1100
+ /**
1101
+ * Returns the `<pc-entity>` element whose backing entity is `entity`, or `null` if the
1102
+ * entity was not created by an element of this application - for example, a node inside a
1103
+ * model's instantiated hierarchy, or an entity created through the engine API.
1104
+ *
1105
+ * @param entity - The entity to look up.
1106
+ * @returns The element backing the entity, or `null`.
1107
+ */
1108
+ elementFromEntity(entity) {
1109
+ return this._entityElements.get(entity) ?? null;
1110
+ }
1111
+ /**
1112
+ * Resolves the element that owns a picked node: the nearest node up the parent chain -
1113
+ * starting with the node itself - that was created by a `<pc-entity>` of this application.
1114
+ * A hit inside a model's instantiated hierarchy therefore resolves to the element hosting
1115
+ * the model.
1116
+ *
1117
+ * @param node - The picked node, or `null`.
1118
+ * @returns The owning element, or `null`.
1119
+ */
1120
+ _elementFromNode(node) {
1121
+ while (node !== null) {
1122
+ const element = this._entityElements.get(node);
1123
+ if (element) {
1124
+ return element;
1125
+ }
1126
+ node = node.parent;
1127
+ }
1128
+ return null;
1129
+ }
1130
+ /**
1131
+ * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1132
+ * on an unlistened child still reaches a listening ancestor.
1133
+ *
1134
+ * @param node - The picked node, or `null`.
1135
+ * @param type - The pointer event type a listener is required for.
1136
+ * @returns The nearest listening element, or `null`.
1137
+ */
1138
+ _elementWithListener(node, type) {
1139
+ while (node !== null) {
1140
+ const element = this._entityElements.get(node);
1141
+ if (element?._hasListeners(type)) {
1142
+ return element;
1143
+ }
1144
+ node = node.parent;
1145
+ }
1146
+ return null;
1147
+ }
957
1148
  // New helper to convert CSS coordinates to canvas (picker) coordinates
958
1149
  _getPickerCoordinates(event) {
959
1150
  // Get the canvas' bounding rectangle in CSS pixels.
@@ -999,56 +1190,44 @@ class AppElement extends AsyncElement {
999
1190
  const node = await this._pickNode(event);
1000
1191
  if (token !== this._pickToken || !this._picker)
1001
1192
  return;
1002
- // Get the currently hovered entity by walking up the hierarchy
1003
- let newHoverEntity = null;
1004
- let currentNode = node;
1005
- while (currentNode !== null) {
1006
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
1007
- if (entityElement) {
1008
- newHoverEntity = entityElement;
1009
- break;
1010
- }
1011
- currentNode = currentNode.parent;
1012
- }
1193
+ // The hovered element is the nearest one up the node's parent chain, listening or not -
1194
+ // dispatch is gated per event type below
1195
+ const newHoverEntity = this._elementFromNode(node);
1013
1196
  // Handle enter/leave events
1014
1197
  if (this._hoveredEntity !== newHoverEntity) {
1015
- if (this._hoveredEntity && this._hoveredEntity.hasListeners('pointerleave')) {
1198
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1016
1199
  this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1017
1200
  }
1018
- if (newHoverEntity && newHoverEntity.hasListeners('pointerenter')) {
1201
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1019
1202
  newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1020
1203
  }
1021
1204
  }
1022
1205
  // Update hover state
1023
1206
  this._hoveredEntity = newHoverEntity;
1024
1207
  // Handle pointermove event
1025
- if (newHoverEntity && newHoverEntity.hasListeners('pointermove')) {
1208
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1026
1209
  newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1027
1210
  }
1028
1211
  }
1029
1212
  async _onPointerDown(event) {
1030
1213
  if (!this._picker || !this.app)
1031
1214
  return;
1032
- let currentNode = await this._pickNode(event);
1215
+ const node = await this._pickNode(event);
1033
1216
  if (!this._picker)
1034
1217
  return; // the element disconnected while the pick was in flight
1035
- while (currentNode !== null) {
1036
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`);
1037
- if (entityElement && entityElement.hasListeners('pointerdown')) {
1038
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1039
- break;
1040
- }
1041
- currentNode = currentNode.parent;
1218
+ const entityElement = this._elementWithListener(node, 'pointerdown');
1219
+ if (entityElement) {
1220
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1042
1221
  }
1043
1222
  }
1044
1223
  async _onPointerUp(event) {
1045
1224
  if (!this._picker || !this.app)
1046
1225
  return;
1047
1226
  const node = await this._pickNode(event);
1048
- if (!node || !this._picker)
1049
- return;
1050
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`);
1051
- if (entityElement && entityElement.hasListeners('pointerup')) {
1227
+ if (!this._picker)
1228
+ return; // the element disconnected while the pick was in flight
1229
+ const entityElement = this._elementWithListener(node, 'pointerup');
1230
+ if (entityElement) {
1052
1231
  entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1053
1232
  }
1054
1233
  }
@@ -1056,50 +1235,64 @@ class AppElement extends AsyncElement {
1056
1235
  if (!this._hasPointerListeners[type] && this._canvas) {
1057
1236
  this._hasPointerListeners[type] = true;
1058
1237
  // For enter/leave events, we need the move handler
1059
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
1060
- this._pointerHandlers.pointermove :
1061
- this._pointerHandlers[type];
1238
+ const handler = type === 'pointerenter' || type === 'pointerleave'
1239
+ ? this._pointerHandlers.pointermove
1240
+ : this._pointerHandlers[type];
1062
1241
  if (handler) {
1063
1242
  this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1064
1243
  }
1065
1244
  }
1066
1245
  }
1067
1246
  _onPointerListenerRemoved(type) {
1068
- const hasListeners = Array.from(this.querySelectorAll('pc-entity'))
1069
- .some(entity => entity.hasListeners(type));
1247
+ const hasListeners = Array.from(this.querySelectorAll('pc-entity')).some((entity) => entity._hasListeners(type));
1070
1248
  if (!hasListeners && this._canvas) {
1071
1249
  this._hasPointerListeners[type] = false;
1072
- const handler = (type === 'pointerenter' || type === 'pointerleave') ?
1073
- this._pointerHandlers.pointermove :
1074
- this._pointerHandlers[type];
1250
+ const handler = type === 'pointerenter' || type === 'pointerleave'
1251
+ ? this._pointerHandlers.pointermove
1252
+ : this._pointerHandlers[type];
1075
1253
  if (handler) {
1076
1254
  this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1077
1255
  }
1078
1256
  }
1079
1257
  }
1080
1258
  /**
1081
- * Sets the alpha flag.
1259
+ * Warns that a graphics option was written too late to have any effect. These options are read
1260
+ * once, when the element connects and creates its graphics device, so a later write updates
1261
+ * only the element's own property - silently, without this.
1262
+ *
1263
+ * @param name - The name of the option, as its attribute.
1264
+ */
1265
+ _warnIfBooted(name) {
1266
+ if (this._optionsLocked) {
1267
+ console.warn(`Attribute '${name}' on <pc-app> is only read when the application boots, so this change has no effect. Set it before the element is connected, or remove and re-insert the element to reboot with the new value.`);
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Sets whether the frame buffer has an alpha channel, which is what lets the page show through
1272
+ * wherever the scene has not drawn. Read only when the application boots.
1082
1273
  * @param value - The alpha flag.
1083
1274
  */
1084
1275
  set alpha(value) {
1276
+ this._warnIfBooted('alpha');
1085
1277
  this._alpha = value;
1086
1278
  }
1087
1279
  /**
1088
- * Gets the alpha flag.
1280
+ * Gets whether the frame buffer has an alpha channel.
1089
1281
  * @returns The alpha flag.
1090
1282
  */
1091
1283
  get alpha() {
1092
1284
  return this._alpha;
1093
1285
  }
1094
1286
  /**
1095
- * Sets the antialias flag.
1287
+ * Sets whether the frame buffer is anti-aliased. Read only when the application boots.
1096
1288
  * @param value - The antialias flag.
1097
1289
  */
1098
1290
  set antialias(value) {
1291
+ this._warnIfBooted('antialias');
1099
1292
  this._antialias = value;
1100
1293
  }
1101
1294
  /**
1102
- * Gets the antialias flag.
1295
+ * Gets whether the frame buffer is anti-aliased.
1103
1296
  * @returns The antialias flag.
1104
1297
  */
1105
1298
  get antialias() {
@@ -1107,10 +1300,11 @@ class AppElement extends AsyncElement {
1107
1300
  }
1108
1301
  /**
1109
1302
  * Sets the graphics backend. Defaults to 'webgpu', which falls back to 'webgl2' if WebGPU
1110
- * is not supported by the browser.
1303
+ * is not supported by the browser. Read only when the application boots.
1111
1304
  * @param value - The graphics backend ('webgpu', 'webgl2', or 'null').
1112
1305
  */
1113
1306
  set backend(value) {
1307
+ this._warnIfBooted('backend');
1114
1308
  this._backend = value;
1115
1309
  }
1116
1310
  /**
@@ -1121,44 +1315,20 @@ class AppElement extends AsyncElement {
1121
1315
  return this._backend;
1122
1316
  }
1123
1317
  /**
1124
- * Sets the depth flag.
1125
- * @param value - The depth flag.
1126
- */
1127
- set depth(value) {
1128
- this._depth = value;
1129
- }
1130
- /**
1131
- * Gets the depth flag.
1132
- * @returns The depth flag.
1133
- */
1134
- get depth() {
1135
- return this._depth;
1136
- }
1137
- /**
1138
- * Gets the hierarchy ready flag.
1139
- * @returns The hierarchy ready flag.
1140
- * @ignore
1141
- */
1142
- get hierarchyReady() {
1143
- return this._hierarchyReady;
1144
- }
1145
- /**
1146
- * Sets the high resolution flag. When true, the application will render at the device's
1147
- * physical resolution. When false, the application will render at CSS resolution.
1148
- * @param value - The high resolution flag.
1318
+ * Sets whether the frame buffer has a depth buffer, which the renderer needs to resolve which
1319
+ * surface is nearest the camera. Read only when the application boots.
1320
+ * @param value - The depth buffer flag.
1149
1321
  */
1150
- set highResolution(value) {
1151
- this._highResolution = value;
1152
- if (this.app) {
1153
- this.app.graphicsDevice.maxPixelRatio = value ? window.devicePixelRatio : 1;
1154
- }
1322
+ set depthBuffer(value) {
1323
+ this._warnIfBooted('depth-buffer');
1324
+ this._depthBuffer = value;
1155
1325
  }
1156
1326
  /**
1157
- * Gets the high resolution flag.
1158
- * @returns The high resolution flag.
1327
+ * Gets whether the frame buffer has a depth buffer.
1328
+ * @returns The depth buffer flag.
1159
1329
  */
1160
- get highResolution() {
1161
- return this._highResolution;
1330
+ get depthBuffer() {
1331
+ return this._depthBuffer;
1162
1332
  }
1163
1333
  /**
1164
1334
  * Sets whether the application shows its built-in loading bar while it boots and preloads its
@@ -1184,21 +1354,45 @@ class AppElement extends AsyncElement {
1184
1354
  return this._loadingBar;
1185
1355
  }
1186
1356
  /**
1187
- * Sets the stencil flag.
1188
- * @param value - The stencil flag.
1357
+ * Sets the cap on the pixel ratio the application renders at. The canvas is sized by the
1358
+ * smaller of this value and the display's own device pixel ratio, so the default of `Infinity`
1359
+ * renders at full physical resolution, `1` renders at CSS resolution, and an intermediate
1360
+ * value such as `2` keeps a dense display sharp without paying for every one of its pixels.
1361
+ * Must be greater than 0. Unlike the other graphics options, this applies immediately.
1362
+ * @param value - The maximum pixel ratio.
1363
+ */
1364
+ set maxPixelRatio(value) {
1365
+ this._maxPixelRatio = value;
1366
+ if (this.app) {
1367
+ this.app.graphicsDevice.maxPixelRatio = value;
1368
+ this.app.resizeCanvas();
1369
+ }
1370
+ }
1371
+ /**
1372
+ * Gets the cap on the pixel ratio the application renders at.
1373
+ * @returns The maximum pixel ratio.
1374
+ */
1375
+ get maxPixelRatio() {
1376
+ return this._maxPixelRatio;
1377
+ }
1378
+ /**
1379
+ * Sets whether the frame buffer has a stencil buffer, which stencil-based effects and UI
1380
+ * masking need. Read only when the application boots.
1381
+ * @param value - The stencil buffer flag.
1189
1382
  */
1190
- set stencil(value) {
1191
- this._stencil = value;
1383
+ set stencilBuffer(value) {
1384
+ this._warnIfBooted('stencil-buffer');
1385
+ this._stencilBuffer = value;
1192
1386
  }
1193
1387
  /**
1194
- * Gets the stencil flag.
1195
- * @returns The stencil flag.
1388
+ * Gets whether the frame buffer has a stencil buffer.
1389
+ * @returns The stencil buffer flag.
1196
1390
  */
1197
- get stencil() {
1198
- return this._stencil;
1391
+ get stencilBuffer() {
1392
+ return this._stencilBuffer;
1199
1393
  }
1200
1394
  static get observedAttributes() {
1201
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
1395
+ return ['alpha', 'antialias', 'backend', 'depth-buffer', 'loading-bar', 'max-pixel-ratio', 'stencil-buffer'];
1202
1396
  }
1203
1397
  attributeChangedCallback(name, _oldValue, newValue) {
1204
1398
  switch (name) {
@@ -1211,17 +1405,17 @@ class AppElement extends AsyncElement {
1211
1405
  case 'backend':
1212
1406
  this.backend = parseEnum(newValue, ['webgpu', 'webgl2', 'null'], 'webgpu', name);
1213
1407
  break;
1214
- case 'depth':
1215
- this.depth = parseBool(newValue, true);
1216
- break;
1217
- case 'high-resolution':
1218
- this.highResolution = parseBool(newValue, true);
1408
+ case 'depth-buffer':
1409
+ this.depthBuffer = parseBool(newValue, true);
1219
1410
  break;
1220
1411
  case 'loading-bar':
1221
1412
  this.loadingBar = parseBool(newValue, true);
1222
1413
  break;
1223
- case 'stencil':
1224
- this.stencil = parseBool(newValue, true);
1414
+ case 'max-pixel-ratio':
1415
+ this.maxPixelRatio = parseNumber(newValue, Infinity, name);
1416
+ break;
1417
+ case 'stencil-buffer':
1418
+ this.stencilBuffer = parseBool(newValue, true);
1225
1419
  break;
1226
1420
  }
1227
1421
  }
@@ -1290,6 +1484,11 @@ class EntityElement extends AsyncElement {
1290
1484
  */
1291
1485
  _built = false;
1292
1486
  _entity = null;
1487
+ /**
1488
+ * The application element this entity is registered with, cached at creation time so the
1489
+ * entity can be unregistered even once this element has left the DOM.
1490
+ */
1491
+ _appElement = null;
1293
1492
  /**
1294
1493
  * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
1295
1494
  * been removed from the document — await {@link whenReady} or the element's `ready()`
@@ -1299,7 +1498,14 @@ class EntityElement extends AsyncElement {
1299
1498
  get entity() {
1300
1499
  return this._entity;
1301
1500
  }
1302
- createEntity(app) {
1501
+ /**
1502
+ * Creates the backing entity. Called by the containing `<pc-app>` element during its boot
1503
+ * sweep, and on connection for elements inserted while the application is already running.
1504
+ *
1505
+ * @param app - The application to create the entity in.
1506
+ * @internal
1507
+ */
1508
+ _createEntity(app) {
1303
1509
  // Guard against double creation. When a subtree is inserted at runtime (e.g. cloning a
1304
1510
  // `<template>`), an ancestor's connectedCallback eagerly creates descendant entities; the
1305
1511
  // descendants' own connectedCallbacks would otherwise create them a second time.
@@ -1319,8 +1525,41 @@ class EntityElement extends AsyncElement {
1319
1525
  if (this._tags.length > 0) {
1320
1526
  entity.tags.add(this._tags);
1321
1527
  }
1528
+ // Register with the owning application, which joins engine nodes back to elements by
1529
+ // identity (never by name), and hook the entity's destruction. The engine fires 'destroy'
1530
+ // for every entity in a destroyed subtree, so the element learns of its entity's death no
1531
+ // matter who causes it: this element, an ancestor, the whole application, or a user
1532
+ // script calling entity.destroy().
1533
+ this._appElement = this.closestApp;
1534
+ this._appElement?._registerEntityElement(entity, this);
1535
+ entity.once('destroy', this._onEntityDestroy, this);
1536
+ }
1537
+ /**
1538
+ * Handles the destruction of the backing entity. Resets the element so a later re-insertion
1539
+ * starts clean: `_built` must be cleared alongside `_entity`, or _buildHierarchy would bail
1540
+ * and a re-created entity would never be parented. Readiness is re-armed for the same
1541
+ * reason — with the entity gone, a resolved ready promise would resume its awaiters against
1542
+ * a null `entity`.
1543
+ *
1544
+ * @param entity - The entity that was destroyed.
1545
+ */
1546
+ _onEntityDestroy(entity) {
1547
+ this._appElement?._unregisterEntityElement(entity);
1548
+ this._appElement = null;
1549
+ this._entity = null;
1550
+ this._built = false;
1551
+ this._resetReady();
1322
1552
  }
1323
- buildHierarchy(app) {
1553
+ /**
1554
+ * Parents the backing entity: under the entity of the nearest ancestor `<pc-entity>` when
1555
+ * there is one, and under the application root otherwise. Called by the containing `<pc-app>`
1556
+ * element once a sweep has created every entity, so a parent's existence never depends on
1557
+ * document order.
1558
+ *
1559
+ * @param app - The application whose root adopts parentless entities.
1560
+ * @internal
1561
+ */
1562
+ _buildHierarchy(app) {
1324
1563
  if (!this.entity || this._built)
1325
1564
  return;
1326
1565
  this._built = true;
@@ -1346,37 +1585,26 @@ class EntityElement extends AsyncElement {
1346
1585
  return;
1347
1586
  }
1348
1587
  // If app is already running, create entity immediately
1349
- if (closestApp.hierarchyReady) {
1588
+ if (closestApp._hierarchyReady) {
1350
1589
  const app = closestApp.app;
1351
- this.createEntity(app);
1352
- this.buildHierarchy(app);
1590
+ this._createEntity(app);
1591
+ this._buildHierarchy(app);
1353
1592
  // Handle any child entities that might exist
1354
1593
  const childEntities = this.querySelectorAll('pc-entity');
1355
1594
  childEntities.forEach((child) => {
1356
- child.createEntity(app);
1595
+ child._createEntity(app);
1357
1596
  });
1358
1597
  childEntities.forEach((child) => {
1359
- child.buildHierarchy(app);
1598
+ child._buildHierarchy(app);
1360
1599
  });
1361
1600
  }
1362
1601
  }
1363
1602
  disconnectedCallback() {
1364
- if (this.entity) {
1365
- // Notify all children that their entities are about to become invalid. Both fields have
1366
- // to be reset here, not just _entity: a descendant's own disconnectedCallback runs after
1367
- // this one and skips its reset behind the `if (this.entity)` guard, because we have
1368
- // already nulled the entity it tests. Leaving _built set would make buildHierarchy bail
1369
- // on re-insertion, so the descendant would get a fresh entity that is never parented.
1370
- const children = this.querySelectorAll('pc-entity');
1371
- children.forEach((child) => {
1372
- child._entity = null;
1373
- child._built = false;
1374
- });
1375
- // Destroy the entity
1376
- this.entity.destroy();
1377
- this._entity = null;
1378
- this._built = false;
1379
- }
1603
+ // Destroying the entity destroys its whole subtree, and the engine fires 'destroy' for
1604
+ // every entity in it - so _onEntityDestroy resets this element AND every descendant
1605
+ // element before the descendants' own disconnectedCallbacks run. Their entities are null
1606
+ // by then, making this call a no-op for them.
1607
+ this._entity?.destroy();
1380
1608
  }
1381
1609
  /**
1382
1610
  * Sets the enabled state of the entity.
@@ -1561,14 +1789,23 @@ class EntityElement extends AsyncElement {
1561
1789
  }
1562
1790
  removeEventListener(type, listener, options) {
1563
1791
  if (this._listeners[type]) {
1564
- this._listeners[type] = this._listeners[type].filter(l => l !== listener);
1792
+ this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
1565
1793
  }
1566
1794
  super.removeEventListener(type, listener, options);
1567
1795
  if (type.startsWith('pointer')) {
1568
1796
  this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1569
1797
  }
1570
1798
  }
1571
- hasListeners(type) {
1799
+ /**
1800
+ * Whether the element has a listener for an event type, registered either with
1801
+ * {@link addEventListener} or with the matching inline `onpointer*` attribute. Read by the
1802
+ * containing `<pc-app>` element to gate pointer event synthesis.
1803
+ *
1804
+ * @param type - The event type.
1805
+ * @returns Whether a listener is registered.
1806
+ * @internal
1807
+ */
1808
+ _hasListeners(type) {
1572
1809
  return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1573
1810
  }
1574
1811
  }
@@ -1859,7 +2096,7 @@ class AssetElement extends AsyncElement {
1859
2096
  const app = appElement.app;
1860
2097
  if (!app)
1861
2098
  return; // pc-app is re-connecting; its own boot will create this asset
1862
- this.createAsset();
2099
+ this._createAsset();
1863
2100
  if (this.asset) {
1864
2101
  app.assets.add(this.asset); // add() auto-loads when preload is true
1865
2102
  if (!this.lazy) {
@@ -1867,13 +2104,15 @@ class AssetElement extends AsyncElement {
1867
2104
  }
1868
2105
  }
1869
2106
  }
1870
- // Never ready if createAsset failed (unsupported asset type)
2107
+ // Never ready if _createAsset failed (unsupported asset type)
1871
2108
  if (this.asset) {
1872
2109
  this._onReady();
1873
2110
  }
1874
2111
  }
1875
2112
  disconnectedCallback() {
1876
- this.destroyAsset();
2113
+ this._destroyAsset();
2114
+ // Re-arm readiness so a re-inserted element announces the asset it creates then
2115
+ this._resetReady();
1877
2116
  }
1878
2117
  _onAssetLoad() {
1879
2118
  this.dispatchEvent(new Event('load'));
@@ -1883,7 +2122,14 @@ class AssetElement extends AsyncElement {
1883
2122
  message: err instanceof Error ? err.message : String(err)
1884
2123
  }));
1885
2124
  }
1886
- createAsset() {
2125
+ /**
2126
+ * Creates the asset from the element's attributes. Called by the containing `<pc-app>`
2127
+ * element during its boot sweep, and on connection for elements inserted while the
2128
+ * application is already running.
2129
+ *
2130
+ * @internal
2131
+ */
2132
+ _createAsset() {
1887
2133
  const id = this.getAttribute('id') || '';
1888
2134
  const src = this.getAttribute('src') || '';
1889
2135
  let type = this.getAttribute('type');
@@ -1973,7 +2219,7 @@ class AssetElement extends AsyncElement {
1973
2219
  }
1974
2220
  return data;
1975
2221
  }
1976
- destroyAsset() {
2222
+ _destroyAsset() {
1977
2223
  if (this.asset) {
1978
2224
  // A caller that keeps the Asset alive must not dispatch on a removed element
1979
2225
  this.asset.off('load', this._onAssetLoad, this);
@@ -2001,6 +2247,13 @@ class AssetElement extends AsyncElement {
2001
2247
  get lazy() {
2002
2248
  return this._lazy;
2003
2249
  }
2250
+ /**
2251
+ * Returns the {@link Asset} created by the `<pc-asset>` element with the given `id`, or
2252
+ * `undefined` if there is no such element or its asset has not been created yet.
2253
+ *
2254
+ * @param id - The `id` of the `<pc-asset>` element.
2255
+ * @returns The asset, or `undefined`.
2256
+ */
2004
2257
  static get(id) {
2005
2258
  const assetElement = document.querySelector(`pc-asset[id="${id}"]`);
2006
2259
  return assetElement?.asset;
@@ -2026,6 +2279,14 @@ class ComponentElement extends AsyncElement {
2026
2279
  _enabled = true;
2027
2280
  _component = null;
2028
2281
  _appElement = null;
2282
+ /**
2283
+ * Incremented on every connect and disconnect. connectedCallback captures the value on entry
2284
+ * and abandons itself wherever it resumes from an await if the value has moved on — so a
2285
+ * callback whose element was removed cannot act on a torn-down tree, and one whose element
2286
+ * was removed and re-inserted (which runs a callback of its own) cannot add the component a
2287
+ * second time.
2288
+ */
2289
+ _connectionGeneration = 0;
2029
2290
  /**
2030
2291
  * Creates a new ComponentElement instance.
2031
2292
  *
@@ -2036,11 +2297,17 @@ class ComponentElement extends AsyncElement {
2036
2297
  super();
2037
2298
  this._componentName = componentName;
2038
2299
  }
2039
- // Method to be overridden by subclasses to provide initial component data
2300
+ /**
2301
+ * Returns the data the component is created with. Overridden by subclasses to supply the
2302
+ * initial values of their cached properties.
2303
+ *
2304
+ * @returns The initial component data.
2305
+ */
2040
2306
  getInitialComponentData() {
2041
2307
  return {};
2042
2308
  }
2043
- async addComponent() {
2309
+ async _addComponent() {
2310
+ const generation = this._connectionGeneration;
2044
2311
  const entityElement = this.closestEntity;
2045
2312
  if (!entityElement) {
2046
2313
  // A component can only exist on an entity, so an element placed outside one is inert.
@@ -2050,19 +2317,42 @@ class ComponentElement extends AsyncElement {
2050
2317
  return;
2051
2318
  }
2052
2319
  await entityElement.ready();
2320
+ // The element may have been removed, or removed and re-inserted, while the entity became
2321
+ // ready — the component belongs to the connection that owns the current generation.
2322
+ if (generation !== this._connectionGeneration) {
2323
+ return;
2324
+ }
2053
2325
  // Add the component to the entity
2054
2326
  const data = this.getInitialComponentData();
2055
2327
  this._component = entityElement.entity.addComponent(this._componentName, data);
2056
2328
  }
2057
- initComponent() { }
2329
+ /**
2330
+ * Configures the newly added component. Overridden by subclasses whose setup goes beyond
2331
+ * the initial data — child-element handling, asset resolution and the like.
2332
+ */
2333
+ initComponent() {
2334
+ // optional hook
2335
+ }
2058
2336
  async connectedCallback() {
2337
+ const generation = ++this._connectionGeneration;
2059
2338
  this._appElement = this.closestApp ?? null;
2060
2339
  await this._appElement?.ready();
2061
- await this.addComponent();
2340
+ // The element may have been removed, or removed and re-inserted, while the application
2341
+ // became ready. A re-insertion runs a connectedCallback of its own, so a stale resume
2342
+ // must not add the component alongside it.
2343
+ if (generation !== this._connectionGeneration) {
2344
+ return;
2345
+ }
2346
+ await this._addComponent();
2347
+ if (generation !== this._connectionGeneration) {
2348
+ return;
2349
+ }
2062
2350
  this.initComponent();
2063
2351
  this._onReady();
2064
2352
  }
2065
2353
  disconnectedCallback() {
2354
+ // Invalidate any connectedCallback still suspended on an await
2355
+ this._connectionGeneration++;
2066
2356
  // Remove the component when the element is disconnected. Skip this when the owning
2067
2357
  // application has already been destroyed — removing a <pc-app> disconnects it before
2068
2358
  // its children, taking the component systems with it.
@@ -2071,6 +2361,7 @@ class ComponentElement extends AsyncElement {
2071
2361
  }
2072
2362
  this._component = null;
2073
2363
  this._appElement = null;
2364
+ this._resetReady();
2074
2365
  }
2075
2366
  /**
2076
2367
  * The PlayCanvas component instance. `null` until the element is ready, and also for an
@@ -2523,6 +2814,10 @@ class ButtonComponentElement extends ComponentElement {
2523
2814
  }
2524
2815
  customElements.define('pc-button', ButtonComponentElement);
2525
2816
 
2817
+ const projections = new Map([
2818
+ ['perspective', PROJECTION_PERSPECTIVE],
2819
+ ['orthographic', PROJECTION_ORTHOGRAPHIC]
2820
+ ]);
2526
2821
  const tonemaps = new Map([
2527
2822
  ['none', TONEMAP_NONE],
2528
2823
  ['linear', TONEMAP_LINEAR],
@@ -2553,7 +2848,7 @@ class CameraComponentElement extends ComponentElement {
2553
2848
  _gamma = 'srgb';
2554
2849
  _horizontalFov = false;
2555
2850
  _nearClip = 0.1;
2556
- _orthographic = false;
2851
+ _projection = 'perspective';
2557
2852
  _orthoHeight = 10;
2558
2853
  _priority = 0;
2559
2854
  _rect = new Vec4(0, 0, 1, 1);
@@ -2577,12 +2872,12 @@ class CameraComponentElement extends ComponentElement {
2577
2872
  gammaCorrection: this._gamma === 'srgb' ? GAMMA_SRGB : GAMMA_NONE,
2578
2873
  horizontalFov: this._horizontalFov,
2579
2874
  nearClip: this._nearClip,
2580
- projection: this._orthographic ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE,
2875
+ projection: projections.get(this._projection) ?? PROJECTION_PERSPECTIVE,
2581
2876
  orthoHeight: this._orthoHeight,
2582
2877
  priority: this._priority,
2583
2878
  rect: this._rect,
2584
2879
  scissorRect: this._scissorRect,
2585
- toneMapping: tonemaps.get(this._tonemap)
2880
+ toneMapping: tonemaps.get(this._tonemap) ?? TONEMAP_NONE
2586
2881
  };
2587
2882
  }
2588
2883
  get xrAvailable() {
@@ -2824,23 +3119,6 @@ class CameraComponentElement extends ComponentElement {
2824
3119
  get nearClip() {
2825
3120
  return this._nearClip;
2826
3121
  }
2827
- /**
2828
- * Sets the orthographic projection of the camera.
2829
- * @param value - The orthographic projection.
2830
- */
2831
- set orthographic(value) {
2832
- this._orthographic = value;
2833
- if (this.component) {
2834
- this.component.projection = value ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE;
2835
- }
2836
- }
2837
- /**
2838
- * Gets the orthographic projection of the camera.
2839
- * @returns The orthographic projection.
2840
- */
2841
- get orthographic() {
2842
- return this._orthographic;
2843
- }
2844
3122
  /**
2845
3123
  * Sets the orthographic height of the camera.
2846
3124
  * @param value - The orthographic height.
@@ -2875,6 +3153,23 @@ class CameraComponentElement extends ComponentElement {
2875
3153
  get priority() {
2876
3154
  return this._priority;
2877
3155
  }
3156
+ /**
3157
+ * Sets the projection of the camera. Use `orthoHeight` to size an orthographic projection.
3158
+ * @param value - The projection ('perspective' or 'orthographic').
3159
+ */
3160
+ set projection(value) {
3161
+ this._projection = value;
3162
+ if (this.component) {
3163
+ this.component.projection = projections.get(value) ?? PROJECTION_PERSPECTIVE;
3164
+ }
3165
+ }
3166
+ /**
3167
+ * Gets the projection of the camera.
3168
+ * @returns The projection.
3169
+ */
3170
+ get projection() {
3171
+ return this._projection;
3172
+ }
2878
3173
  /**
2879
3174
  * Sets the rect of the camera.
2880
3175
  * @param value - The rect.
@@ -2941,9 +3236,9 @@ class CameraComponentElement extends ComponentElement {
2941
3236
  'gamma',
2942
3237
  'horizontal-fov',
2943
3238
  'near-clip',
2944
- 'orthographic',
2945
3239
  'ortho-height',
2946
3240
  'priority',
3241
+ 'projection',
2947
3242
  'rect',
2948
3243
  'scissor-rect',
2949
3244
  'tonemap'
@@ -2988,15 +3283,15 @@ class CameraComponentElement extends ComponentElement {
2988
3283
  case 'near-clip':
2989
3284
  this.nearClip = parseNumber(newValue, 0.1, name);
2990
3285
  break;
2991
- case 'orthographic':
2992
- this.orthographic = parseBool(newValue, false);
2993
- break;
2994
3286
  case 'ortho-height':
2995
3287
  this.orthoHeight = parseNumber(newValue, 10, name);
2996
3288
  break;
2997
3289
  case 'priority':
2998
3290
  this.priority = parseNumber(newValue, 0, name);
2999
3291
  break;
3292
+ case 'projection':
3293
+ this.projection = parseEnum(newValue, projections, 'perspective', name);
3294
+ break;
3000
3295
  case 'rect':
3001
3296
  this.rect = parseVec4(newValue, new Vec4(0, 0, 1, 1), name);
3002
3297
  break;
@@ -3124,7 +3419,17 @@ class CollisionComponentElement extends ComponentElement {
3124
3419
  return this._type;
3125
3420
  }
3126
3421
  static get observedAttributes() {
3127
- return [...super.observedAttributes, 'angular-offset', 'axis', 'convex-hull', 'half-extents', 'height', 'linear-offset', 'radius', 'type'];
3422
+ return [
3423
+ ...super.observedAttributes,
3424
+ 'angular-offset',
3425
+ 'axis',
3426
+ 'convex-hull',
3427
+ 'half-extents',
3428
+ 'height',
3429
+ 'linear-offset',
3430
+ 'radius',
3431
+ 'type'
3432
+ ];
3128
3433
  }
3129
3434
  attributeChangedCallback(name, _oldValue, newValue) {
3130
3435
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -4829,7 +5134,7 @@ class ParticleSystemComponentElement extends ComponentElement {
4829
5134
  }
4830
5135
  // Set all the config properties on the component
4831
5136
  for (const key in resource) {
4832
- if (resource.hasOwnProperty(key)) {
5137
+ if (Object.hasOwn(resource, key)) {
4833
5138
  this.component[key] = resource[key];
4834
5139
  }
4835
5140
  }
@@ -4902,10 +5207,7 @@ class ParticleSystemComponentElement extends ComponentElement {
4902
5207
  }
4903
5208
  }
4904
5209
  static get observedAttributes() {
4905
- return [
4906
- ...super.observedAttributes,
4907
- 'asset'
4908
- ];
5210
+ return [...super.observedAttributes, 'asset'];
4909
5211
  }
4910
5212
  attributeChangedCallback(name, _oldValue, newValue) {
4911
5213
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -5065,7 +5367,7 @@ class MaterialElement extends HTMLElement {
5065
5367
  _twoSidedLighting = false;
5066
5368
  _useFog = true;
5067
5369
  _useLighting = true;
5068
- // Diverges from the engine default of false - see the class docblock and createMaterial()
5370
+ // Diverges from the engine default of false - see the class docblock and _createMaterial()
5069
5371
  _useMetalness = true;
5070
5372
  _useMetalnessSpecularColor = false;
5071
5373
  _useSkybox = true;
@@ -5078,6 +5380,10 @@ class MaterialElement extends HTMLElement {
5078
5380
  _mapHandles = new Map();
5079
5381
  _updateScheduled = false;
5080
5382
  _glossConflictWarned = false;
5383
+ /**
5384
+ * The material. `null` until the containing application has created it — an element present
5385
+ * at startup has its material once the application is ready.
5386
+ */
5081
5387
  material = null;
5082
5388
  async connectedCallback() {
5083
5389
  const appElement = this.parentElement?.closest('pc-app') ?? null;
@@ -5095,10 +5401,17 @@ class MaterialElement extends HTMLElement {
5095
5401
  if (!this.material) {
5096
5402
  if (!appElement.app)
5097
5403
  return; // pc-app is re-connecting; its own boot will create this
5098
- this.createMaterial();
5404
+ this._createMaterial();
5099
5405
  }
5100
5406
  }
5101
- createMaterial() {
5407
+ /**
5408
+ * Creates the material from the element's cached properties. Called by the containing
5409
+ * `<pc-app>` element during its boot sweep, and on connection for elements inserted while
5410
+ * the application is already running.
5411
+ *
5412
+ * @internal
5413
+ */
5414
+ _createMaterial() {
5102
5415
  const material = new StandardMaterial();
5103
5416
  this.material = material;
5104
5417
  material.alphaTest = this._alphaTest;
@@ -5225,9 +5538,9 @@ class MaterialElement extends HTMLElement {
5225
5538
  * warning latches and reports once per episode, clearing when the clash is resolved.
5226
5539
  */
5227
5540
  _warnGlossConflict() {
5228
- const quote = (names) => `'${names.join('\', \'')}'`;
5229
- const roughness = roughnessAliases.filter(name => this.hasAttribute(name));
5230
- const gloss = glossConflicts.filter(name => this.hasAttribute(name));
5541
+ const quote = (names) => `'${names.join("', '")}'`;
5542
+ const roughness = roughnessAliases.filter((name) => this.hasAttribute(name));
5543
+ const gloss = glossConflicts.filter((name) => this.hasAttribute(name));
5231
5544
  if (roughness.length === 0 || gloss.length === 0) {
5232
5545
  this._glossConflictWarned = false;
5233
5546
  return;
@@ -5245,7 +5558,7 @@ class MaterialElement extends HTMLElement {
5245
5558
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
5246
5559
  * @param slot - The material property to write.
5247
5560
  */
5248
- setMap(id, slot) {
5561
+ _setMap(id, slot) {
5249
5562
  // Drop any load still pending for this slot - its texture is no longer the one we want
5250
5563
  this._mapHandles.get(slot)?.off();
5251
5564
  this._mapHandles.delete(slot);
@@ -5339,7 +5652,7 @@ class MaterialElement extends HTMLElement {
5339
5652
  */
5340
5653
  set aoMap(value) {
5341
5654
  this._aoMap = value;
5342
- this.setMap(value, 'aoMap');
5655
+ this._setMap(value, 'aoMap');
5343
5656
  }
5344
5657
  /**
5345
5658
  * Gets the id of the `pc-asset` used as the ambient occlusion map.
@@ -5571,7 +5884,7 @@ class MaterialElement extends HTMLElement {
5571
5884
  */
5572
5885
  set diffuseMap(value) {
5573
5886
  this._diffuseMap = value;
5574
- this.setMap(value, 'diffuseMap');
5887
+ this._setMap(value, 'diffuseMap');
5575
5888
  }
5576
5889
  /**
5577
5890
  * Gets the id of the `pc-asset` used as the diffuse map.
@@ -5712,7 +6025,7 @@ class MaterialElement extends HTMLElement {
5712
6025
  */
5713
6026
  set emissiveMap(value) {
5714
6027
  this._emissiveMap = value;
5715
- this.setMap(value, 'emissiveMap');
6028
+ this._setMap(value, 'emissiveMap');
5716
6029
  }
5717
6030
  /**
5718
6031
  * Gets the id of the `pc-asset` used as the emissive map.
@@ -5890,7 +6203,7 @@ class MaterialElement extends HTMLElement {
5890
6203
  */
5891
6204
  set glossMap(value) {
5892
6205
  this._glossMap = value;
5893
- this.setMap(value, 'glossMap');
6206
+ this._setMap(value, 'glossMap');
5894
6207
  }
5895
6208
  /**
5896
6209
  * Gets the id of the `pc-asset` used as the gloss map.
@@ -5995,7 +6308,7 @@ class MaterialElement extends HTMLElement {
5995
6308
  */
5996
6309
  set heightMap(value) {
5997
6310
  this._heightMap = value;
5998
- this.setMap(value, 'heightMap');
6311
+ this._setMap(value, 'heightMap');
5999
6312
  }
6000
6313
  /**
6001
6314
  * Gets the id of the `pc-asset` used as the height map.
@@ -6136,7 +6449,7 @@ class MaterialElement extends HTMLElement {
6136
6449
  */
6137
6450
  set metalnessMap(value) {
6138
6451
  this._metalnessMap = value;
6139
- this.setMap(value, 'metalnessMap');
6452
+ this._setMap(value, 'metalnessMap');
6140
6453
  }
6141
6454
  /**
6142
6455
  * Gets the id of the `pc-asset` used as the metalness map.
@@ -6241,7 +6554,7 @@ class MaterialElement extends HTMLElement {
6241
6554
  */
6242
6555
  set normalMap(value) {
6243
6556
  this._normalMap = value;
6244
- this.setMap(value, 'normalMap');
6557
+ this._setMap(value, 'normalMap');
6245
6558
  }
6246
6559
  /**
6247
6560
  * Gets the id of the `pc-asset` used as the normal map.
@@ -6329,7 +6642,7 @@ class MaterialElement extends HTMLElement {
6329
6642
  set occludeDirect(value) {
6330
6643
  this._occludeDirect = value;
6331
6644
  if (this.material) {
6332
- // @ts-ignore see createMaterial() - the engine mistypes occludeDirect as a number
6645
+ // @ts-ignore see _createMaterial() - the engine mistypes occludeDirect as a number
6333
6646
  this.material.occludeDirect = value;
6334
6647
  this._scheduleUpdate();
6335
6648
  }
@@ -6421,7 +6734,7 @@ class MaterialElement extends HTMLElement {
6421
6734
  */
6422
6735
  set opacityMap(value) {
6423
6736
  this._opacityMap = value;
6424
- this.setMap(value, 'opacityMap');
6737
+ this._setMap(value, 'opacityMap');
6425
6738
  }
6426
6739
  /**
6427
6740
  * Gets the id of the `pc-asset` used as the opacity map.
@@ -6739,6 +7052,13 @@ class MaterialElement extends HTMLElement {
6739
7052
  get useTonemap() {
6740
7053
  return this._useTonemap;
6741
7054
  }
7055
+ /**
7056
+ * Returns the {@link StandardMaterial} created by the `<pc-material>` element with the given
7057
+ * `id`, or `undefined` if there is no such element or its material has not been created yet.
7058
+ *
7059
+ * @param id - The `id` of the `<pc-material>` element.
7060
+ * @returns The material, or `undefined`.
7061
+ */
6742
7062
  static get(id) {
6743
7063
  const materialElement = document.querySelector(`pc-material[id="${id}"]`);
6744
7064
  return materialElement?.material;
@@ -7377,7 +7697,18 @@ class RigidBodyComponentElement extends ComponentElement {
7377
7697
  return this._type;
7378
7698
  }
7379
7699
  static get observedAttributes() {
7380
- return [...super.observedAttributes, 'angular-damping', 'angular-factor', 'friction', 'linear-damping', 'linear-factor', 'mass', 'restitution', 'rolling-friction', 'type'];
7700
+ return [
7701
+ ...super.observedAttributes,
7702
+ 'angular-damping',
7703
+ 'angular-factor',
7704
+ 'friction',
7705
+ 'linear-damping',
7706
+ 'linear-factor',
7707
+ 'mass',
7708
+ 'restitution',
7709
+ 'rolling-friction',
7710
+ 'type'
7711
+ ];
7381
7712
  }
7382
7713
  attributeChangedCallback(name, _oldValue, newValue) {
7383
7714
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -7414,6 +7745,14 @@ class RigidBodyComponentElement extends ComponentElement {
7414
7745
  }
7415
7746
  customElements.define('pc-rigidbody', RigidBodyComponentElement);
7416
7747
 
7748
+ // The engine's SCALEMODE_* constants are the strings 'none' and 'blend', so this map happens to be
7749
+ // an identity. It is still the right shape: it supplies parseEnum's valid-name list, it is what the
7750
+ // manifest generator reads the enum values from, and it keeps the attribute vocabulary independent
7751
+ // of constants the engine is free to change.
7752
+ const scaleModes = new Map([
7753
+ ['none', SCALEMODE_NONE],
7754
+ ['blend', SCALEMODE_BLEND]
7755
+ ]);
7417
7756
  /**
7418
7757
  * The ScreenComponentElement interface provides properties and methods for manipulating
7419
7758
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-screen/ | `<pc-screen>`} elements.
@@ -7427,7 +7766,7 @@ class ScreenComponentElement extends ComponentElement {
7427
7766
  _resolution = new Vec2(640, 320);
7428
7767
  _referenceResolution = new Vec2(640, 320);
7429
7768
  _priority = 0;
7430
- _blend = false;
7769
+ _scaleMode = 'none';
7431
7770
  _scaleBlend = 0.5;
7432
7771
  /** @ignore */
7433
7772
  constructor() {
@@ -7439,7 +7778,7 @@ class ScreenComponentElement extends ComponentElement {
7439
7778
  referenceResolution: this._referenceResolution,
7440
7779
  resolution: this._resolution,
7441
7780
  scaleBlend: this._scaleBlend,
7442
- scaleMode: this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE,
7781
+ scaleMode: scaleModes.get(this._scaleMode) ?? SCALEMODE_NONE,
7443
7782
  screenSpace: this._screenSpace
7444
7783
  };
7445
7784
  }
@@ -7477,23 +7816,44 @@ class ScreenComponentElement extends ComponentElement {
7477
7816
  get resolution() {
7478
7817
  return this._resolution;
7479
7818
  }
7819
+ /**
7820
+ * Sets how the screen's `resolution` and `referenceResolution` are weighted against each other
7821
+ * when `scaleMode` is `blend`, from 0 (follow the resolution) to 1 (follow the reference
7822
+ * resolution). Ignored while `scaleMode` is `none`.
7823
+ * @param value - The scale blend factor.
7824
+ */
7480
7825
  set scaleBlend(value) {
7481
7826
  this._scaleBlend = value;
7482
7827
  if (this.component) {
7483
7828
  this.component.scaleBlend = this._scaleBlend;
7484
7829
  }
7485
7830
  }
7831
+ /**
7832
+ * Gets how the screen's resolutions are weighted against each other.
7833
+ * @returns The scale blend factor.
7834
+ */
7486
7835
  get scaleBlend() {
7487
7836
  return this._scaleBlend;
7488
7837
  }
7489
- set blend(value) {
7490
- this._blend = value;
7838
+ /**
7839
+ * Sets how the screen scales its contents. `none` renders at `resolution` and ignores
7840
+ * `referenceResolution`; `blend` scales between the two, weighted by `scaleBlend`, which is what
7841
+ * keeps a UI laid out at one resolution usable at another. Requires `screenSpace` - the engine
7842
+ * forces `none` on a world-space screen, which does not support scaling.
7843
+ * @param value - The scale mode ('none' or 'blend').
7844
+ */
7845
+ set scaleMode(value) {
7846
+ this._scaleMode = value;
7491
7847
  if (this.component) {
7492
- this.component.scaleMode = this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE;
7848
+ this.component.scaleMode = scaleModes.get(value) ?? SCALEMODE_NONE;
7493
7849
  }
7494
7850
  }
7495
- get blend() {
7496
- return this._blend;
7851
+ /**
7852
+ * Gets how the screen scales its contents.
7853
+ * @returns The scale mode.
7854
+ */
7855
+ get scaleMode() {
7856
+ return this._scaleMode;
7497
7857
  }
7498
7858
  set screenSpace(value) {
7499
7859
  this._screenSpace = value;
@@ -7507,12 +7867,12 @@ class ScreenComponentElement extends ComponentElement {
7507
7867
  static get observedAttributes() {
7508
7868
  return [
7509
7869
  ...super.observedAttributes,
7510
- 'blend',
7511
7870
  'screen-space',
7512
7871
  'resolution',
7513
7872
  'reference-resolution',
7514
7873
  'priority',
7515
- 'scale-blend'
7874
+ 'scale-blend',
7875
+ 'scale-mode'
7516
7876
  ];
7517
7877
  }
7518
7878
  attributeChangedCallback(name, _oldValue, newValue) {
@@ -7530,8 +7890,8 @@ class ScreenComponentElement extends ComponentElement {
7530
7890
  case 'scale-blend':
7531
7891
  this.scaleBlend = parseNumber(newValue, 0.5, name);
7532
7892
  break;
7533
- case 'blend':
7534
- this.blend = parseBool(newValue, false);
7893
+ case 'scale-mode':
7894
+ this.scaleMode = parseEnum(newValue, scaleModes, 'none', name);
7535
7895
  break;
7536
7896
  case 'screen-space':
7537
7897
  this.screenSpace = parseBool(newValue, false);
@@ -7653,13 +8013,7 @@ class ScrollbarComponentElement extends ComponentElement {
7653
8013
  return this._handle;
7654
8014
  }
7655
8015
  static get observedAttributes() {
7656
- return [
7657
- ...super.observedAttributes,
7658
- 'orientation',
7659
- 'value',
7660
- 'handle-size',
7661
- 'handle'
7662
- ];
8016
+ return [...super.observedAttributes, 'orientation', 'value', 'handle-size', 'handle'];
7663
8017
  }
7664
8018
  attributeChangedCallback(name, _oldValue, newValue) {
7665
8019
  super.attributeChangedCallback(name, _oldValue, newValue);
@@ -7754,7 +8108,8 @@ class ScrollViewComponentElement extends ComponentElement {
7754
8108
  return super.component;
7755
8109
  }
7756
8110
  /**
7757
- * Sets whether horizontal scrolling is enabled.
8111
+ * Sets whether scrolling along the horizontal axis is enabled. This is a toggle, unlike the
8112
+ * `orientation` of a `<pc-scrollbar>`, for which `horizontal` is one of the accepted values.
7758
8113
  * @param value - Whether horizontal scrolling is enabled.
7759
8114
  */
7760
8115
  set horizontal(value) {
@@ -7764,14 +8119,15 @@ class ScrollViewComponentElement extends ComponentElement {
7764
8119
  }
7765
8120
  }
7766
8121
  /**
7767
- * Gets whether horizontal scrolling is enabled.
8122
+ * Gets whether scrolling along the horizontal axis is enabled.
7768
8123
  * @returns Whether horizontal scrolling is enabled.
7769
8124
  */
7770
8125
  get horizontal() {
7771
8126
  return this._horizontal;
7772
8127
  }
7773
8128
  /**
7774
- * Sets whether vertical scrolling is enabled.
8129
+ * Sets whether scrolling along the vertical axis is enabled. This is a toggle, unlike the
8130
+ * `orientation` of a `<pc-scrollbar>`, for which `vertical` is one of the accepted values.
7775
8131
  * @param value - Whether vertical scrolling is enabled.
7776
8132
  */
7777
8133
  set vertical(value) {
@@ -7781,7 +8137,7 @@ class ScrollViewComponentElement extends ComponentElement {
7781
8137
  }
7782
8138
  }
7783
8139
  /**
7784
- * Gets whether vertical scrolling is enabled.
8140
+ * Gets whether scrolling along the vertical axis is enabled.
7785
8141
  * @returns Whether vertical scrolling is enabled.
7786
8142
  */
7787
8143
  get vertical() {
@@ -7883,7 +8239,8 @@ class ScrollViewComponentElement extends ComponentElement {
7883
8239
  set horizontalScrollbarVisibility(value) {
7884
8240
  this._horizontalScrollbarVisibility = value;
7885
8241
  if (this.component) {
7886
- this.component.horizontalScrollbarVisibility = visibilities.get(value) ?? SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8242
+ this.component.horizontalScrollbarVisibility =
8243
+ visibilities.get(value) ?? SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
7887
8244
  }
7888
8245
  }
7889
8246
  /**
@@ -7901,7 +8258,8 @@ class ScrollViewComponentElement extends ComponentElement {
7901
8258
  set verticalScrollbarVisibility(value) {
7902
8259
  this._verticalScrollbarVisibility = value;
7903
8260
  if (this.component) {
7904
- this.component.verticalScrollbarVisibility = visibilities.get(value) ?? SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
8261
+ this.component.verticalScrollbarVisibility =
8262
+ visibilities.get(value) ?? SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED;
7905
8263
  }
7906
8264
  }
7907
8265
  /**
@@ -8088,15 +8446,9 @@ customElements.define('pc-scrollview', ScrollViewComponentElement);
8088
8446
  class ScriptElement extends AsyncElement {
8089
8447
  _attributes = {};
8090
8448
  _enabled = true;
8091
- /**
8092
- * Whether readiness has been signalled. Creation can happen more than once over an
8093
- * element's life (a runtime `name` change recreates the instance), but `ready` is a
8094
- * one-shot signal, so only the first successful creation fires it.
8095
- */
8096
- _readySignalled = false;
8097
8449
  /**
8098
8450
  * The Script instance created for this element by its parent `<pc-scripts>` element.
8099
- * @ignore
8451
+ * @internal
8100
8452
  */
8101
8453
  _script = null;
8102
8454
  /**
@@ -8178,14 +8530,20 @@ class ScriptElement extends AsyncElement {
8178
8530
  console.warn(`pc-script '${this.getAttribute('name')}' must be a direct child of pc-scripts - script not created`);
8179
8531
  }
8180
8532
  }
8533
+ disconnectedCallback() {
8534
+ // Re-arm readiness so a re-inserted element announces the instance created for it then.
8535
+ // `_script` is deliberately NOT cleared here: the parent's mutation observer processes
8536
+ // this removal afterwards and reads it to establish which engine script this element
8537
+ // owned - the parent is what clears it.
8538
+ this._resetReady();
8539
+ }
8181
8540
  /**
8182
8541
  * Called by the parent `<pc-scripts>` element when the script instance has been created.
8183
- * @ignore
8542
+ * Creation can happen more than once per connection (a runtime `name` change recreates the
8543
+ * instance), but `_onReady` signals readiness at most once per cycle.
8544
+ * @internal
8184
8545
  */
8185
8546
  _onScriptCreated() {
8186
- if (this._readySignalled)
8187
- return;
8188
- this._readySignalled = true;
8189
8547
  this._onReady();
8190
8548
  }
8191
8549
  static get observedAttributes() {
@@ -8230,10 +8588,34 @@ customElements.define('pc-script', ScriptElement);
8230
8588
  */
8231
8589
  const RESERVED_ATTRIBUTES = new Set([
8232
8590
  ...ScriptElement.observedAttributes,
8233
- 'accesskey', 'autocapitalize', 'autofocus', 'class', 'contenteditable', 'dir', 'draggable',
8234
- 'exportparts', 'hidden', 'id', 'inert', 'is', 'itemid', 'itemprop', 'itemref', 'itemscope',
8235
- 'itemtype', 'lang', 'nonce', 'part', 'popover', 'role', 'slot', 'spellcheck', 'style',
8236
- 'tabindex', 'title', 'translate'
8591
+ 'accesskey',
8592
+ 'autocapitalize',
8593
+ 'autofocus',
8594
+ 'class',
8595
+ 'contenteditable',
8596
+ 'dir',
8597
+ 'draggable',
8598
+ 'exportparts',
8599
+ 'hidden',
8600
+ 'id',
8601
+ 'inert',
8602
+ 'is',
8603
+ 'itemid',
8604
+ 'itemprop',
8605
+ 'itemref',
8606
+ 'itemscope',
8607
+ 'itemtype',
8608
+ 'lang',
8609
+ 'nonce',
8610
+ 'part',
8611
+ 'popover',
8612
+ 'role',
8613
+ 'slot',
8614
+ 'spellcheck',
8615
+ 'style',
8616
+ 'tabindex',
8617
+ 'title',
8618
+ 'translate'
8237
8619
  ]);
8238
8620
  /**
8239
8621
  * Checks whether a `pc-script` attribute name is reserved (and so never maps to a script
@@ -8245,18 +8627,25 @@ const RESERVED_ATTRIBUTES = new Set([
8245
8627
  * @returns Whether the attribute name is reserved.
8246
8628
  */
8247
8629
  const isReservedAttribute = (name) => {
8248
- return RESERVED_ATTRIBUTES.has(name) ||
8630
+ return (RESERVED_ATTRIBUTES.has(name) ||
8249
8631
  name.startsWith('data-') ||
8250
8632
  name.startsWith('aria-') ||
8251
8633
  name.startsWith('_') ||
8252
- (name.startsWith('on') && name in HTMLElement.prototype);
8634
+ (name.startsWith('on') && name in HTMLElement.prototype));
8253
8635
  };
8254
8636
  /**
8255
8637
  * Script API members that per-property attributes must never overwrite: the engine bindings and
8256
8638
  * the (optional, so possibly undefined) lifecycle methods.
8257
8639
  */
8258
8640
  const SCRIPT_API_MEMBERS = new Set([
8259
- 'app', 'entity', 'destroy', 'initialize', 'postInitialize', 'postUpdate', 'swap', 'update'
8641
+ 'app',
8642
+ 'entity',
8643
+ 'destroy',
8644
+ 'initialize',
8645
+ 'postInitialize',
8646
+ 'postUpdate',
8647
+ 'swap',
8648
+ 'update'
8260
8649
  ]);
8261
8650
  /**
8262
8651
  * Converts a kebab-case attribute name to the camelCase script attribute name.
@@ -8272,7 +8661,7 @@ const kebabToCamel = (name) => {
8272
8661
  * @returns The kebab-case name.
8273
8662
  */
8274
8663
  const camelToKebab = (name) => {
8275
- return name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`);
8664
+ return name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
8276
8665
  };
8277
8666
  /**
8278
8667
  * Resolves an `asset:` prefix to the Asset created by the `pc-asset` element with that id.
@@ -8471,7 +8860,10 @@ class ScriptComponentElement extends ComponentElement {
8471
8860
  // Only recurse into plain objects. Class instances (Vec3, Color, Asset, Entity...)
8472
8861
  // are leaf values assigned whole, so accessor-typed script attributes receive them
8473
8862
  // through their setters instead of having a getter's returned copy mutated.
8474
- if (value && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype) {
8863
+ if (value &&
8864
+ typeof value === 'object' &&
8865
+ !Array.isArray(value) &&
8866
+ Object.getPrototypeOf(value) === Object.prototype) {
8475
8867
  if (!current || typeof current !== 'object') {
8476
8868
  target[key] = {};
8477
8869
  }
@@ -8489,7 +8881,11 @@ class ScriptComponentElement extends ComponentElement {
8489
8881
  * @returns Whether the value is a math type.
8490
8882
  */
8491
8883
  isMathType(value) {
8492
- return value instanceof Vec2 || value instanceof Vec3 || value instanceof Vec4 || value instanceof Color || value instanceof Quat;
8884
+ return (value instanceof Vec2 ||
8885
+ value instanceof Vec3 ||
8886
+ value instanceof Vec4 ||
8887
+ value instanceof Color ||
8888
+ value instanceof Quat);
8493
8889
  }
8494
8890
  /**
8495
8891
  * Converts a plain numeric array to the math type of `current`. A 3-element array targeting
@@ -8502,7 +8898,7 @@ class ScriptComponentElement extends ComponentElement {
8502
8898
  * @returns The converted value, or `null`.
8503
8899
  */
8504
8900
  arrayToMathType(current, value, key) {
8505
- if (value.every(component => typeof component === 'number' && Number.isFinite(component))) {
8901
+ if (value.every((component) => typeof component === 'number' && Number.isFinite(component))) {
8506
8902
  if (current instanceof Vec2 && value.length === 2)
8507
8903
  return new Vec2(value);
8508
8904
  if (current instanceof Vec3 && value.length === 3)
@@ -8774,7 +9170,10 @@ class ScriptComponentElement extends ComponentElement {
8774
9170
  mutation.removedNodes.forEach((node) => {
8775
9171
  if (node instanceof ScriptElement) {
8776
9172
  const scriptName = node.getAttribute('name');
8777
- if (scriptName && node._script && this.component && this.component.get(scriptName) === node._script) {
9173
+ if (scriptName &&
9174
+ node._script &&
9175
+ this.component &&
9176
+ this.component.get(scriptName) === node._script) {
8778
9177
  this.destroyScript(scriptName);
8779
9178
  }
8780
9179
  node._script = null;
@@ -9023,18 +9422,27 @@ class SoundSlotElement extends AsyncElement {
9023
9422
  * emit a misleading "must be a direct child" warning for what is an ordinary removal.
9024
9423
  */
9025
9424
  _soundElement = null;
9425
+ /**
9426
+ * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
9427
+ * a resume from an await abandons itself if the value has moved on, so a stale callback can
9428
+ * neither act on a torn-down tree nor add its slot alongside a re-inserted element's own
9429
+ * callback.
9430
+ */
9431
+ _connectionGeneration = 0;
9026
9432
  /**
9027
9433
  * The sound slot.
9028
9434
  */
9029
9435
  soundSlot = null;
9030
9436
  async connectedCallback() {
9437
+ const generation = ++this._connectionGeneration;
9031
9438
  const soundElement = this.soundElement;
9032
9439
  await soundElement?.ready();
9033
- // The element may have been removed, or its parent torn down, while we were waiting. A
9034
- // <pc-app> disconnects before its children, so by the time we resume the component can
9035
- // already be gone - see the matching guard in disconnectedCallback below.
9440
+ // The element may have been removed (perhaps re-inserted, which runs a callback of its
9441
+ // own), or its parent torn down, while we were waiting. A <pc-app> disconnects before
9442
+ // its children, so by the time we resume the component can already be gone - see the
9443
+ // matching guard in disconnectedCallback below.
9036
9444
  const component = soundElement?.component;
9037
- if (!this.isConnected || !component) {
9445
+ if (generation !== this._connectionGeneration || !component) {
9038
9446
  return;
9039
9447
  }
9040
9448
  const options = {
@@ -9057,12 +9465,15 @@ class SoundSlotElement extends AsyncElement {
9057
9465
  this._onReady();
9058
9466
  }
9059
9467
  disconnectedCallback() {
9468
+ // Invalidate any connectedCallback still suspended on an await
9469
+ this._connectionGeneration++;
9060
9470
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
9061
9471
  // by now. The component itself is null if the parent <pc-sound> (or the whole <pc-app>) is
9062
9472
  // being torn down — parents disconnect first and have already removed the component.
9063
9473
  this._soundElement?.component?.removeSlot(this._name);
9064
9474
  this._soundElement = null;
9065
9475
  this.soundSlot = null;
9476
+ this._resetReady();
9066
9477
  }
9067
9478
  get soundElement() {
9068
9479
  const soundElement = this.parentElement;
@@ -9462,39 +9873,85 @@ customElements.define('pc-gsplat', GSplatComponentElement);
9462
9873
  class ModelElement extends AsyncElement {
9463
9874
  _asset = '';
9464
9875
  _entity = null;
9876
+ /**
9877
+ * Incremented on every new load and on disconnect, and captured by a load when it starts. A
9878
+ * load that resumes from an await or a load callback abandons itself if the value has moved
9879
+ * on, so a superseded load can neither instantiate a second entity nor parent one that has
9880
+ * since been destroyed.
9881
+ */
9882
+ _loadGeneration = 0;
9883
+ /**
9884
+ * The pending asset-load subscription of the current load, if it is waiting for its asset.
9885
+ * Held so that whatever supersedes the load can detach the handler from the asset, rather
9886
+ * than leave it registered until the asset loads (or forever, if it never does).
9887
+ */
9888
+ _loadHandle = null;
9889
+ /**
9890
+ * The root entity of the instantiated model. `null` until the container asset has loaded
9891
+ * and been instantiated, and again once the element has been removed from the document.
9892
+ * @returns The model's root entity, or `null`.
9893
+ */
9894
+ get entity() {
9895
+ return this._entity;
9896
+ }
9465
9897
  connectedCallback() {
9466
9898
  this._loadModel();
9467
9899
  this._onReady();
9468
9900
  }
9469
9901
  disconnectedCallback() {
9902
+ this._loadGeneration++;
9903
+ this._detachLoadHandler();
9470
9904
  this._unloadModel();
9905
+ this._resetReady();
9906
+ }
9907
+ _detachLoadHandler() {
9908
+ this._loadHandle?.off();
9909
+ this._loadHandle = null;
9471
9910
  }
9472
9911
  _instantiate(container) {
9473
- this._entity = container.instantiateRenderEntity();
9912
+ const generation = this._loadGeneration;
9913
+ const entity = container.instantiateRenderEntity();
9914
+ this._entity = entity;
9474
9915
  // @ts-ignore
9475
9916
  if (container.animations.length > 0) {
9476
- this._entity.addComponent('anim');
9917
+ entity.addComponent('anim');
9477
9918
  // @ts-ignore
9478
- this._entity.anim.assignAnimation('animation', container.animations[0].resource);
9919
+ entity.anim.assignAnimation('animation', container.animations[0].resource);
9479
9920
  }
9921
+ // The parent's readiness re-arms when it is torn down, so these can resume in a later
9922
+ // connection cycle. The entity is captured above and the generation re-checked, so a
9923
+ // stale resume cannot parent an entity a newer cycle has already destroyed.
9480
9924
  const parentEntityElement = this.closestEntity;
9481
9925
  if (parentEntityElement) {
9482
9926
  parentEntityElement.ready().then(() => {
9483
- parentEntityElement.entity.addChild(this._entity);
9927
+ if (generation !== this._loadGeneration) {
9928
+ return;
9929
+ }
9930
+ parentEntityElement.entity.addChild(entity);
9484
9931
  });
9485
9932
  }
9486
9933
  else {
9487
9934
  const appElement = this.closestApp;
9488
9935
  if (appElement) {
9489
9936
  appElement.ready().then(() => {
9490
- appElement.app.root.addChild(this._entity);
9937
+ if (generation !== this._loadGeneration) {
9938
+ return;
9939
+ }
9940
+ appElement.app.root.addChild(entity);
9491
9941
  });
9492
9942
  }
9493
9943
  }
9494
9944
  }
9495
9945
  async _loadModel() {
9496
9946
  this._unloadModel();
9947
+ // Supersede any load already in flight - only the newest load may instantiate
9948
+ const generation = ++this._loadGeneration;
9949
+ this._detachLoadHandler();
9497
9950
  const appElement = await this.closestApp?.ready();
9951
+ // The element may have been removed, or another load started, while we waited
9952
+ if (generation !== this._loadGeneration) {
9953
+ return;
9954
+ }
9498
9955
  const app = appElement?.app;
9499
9956
  const asset = AssetElement.get(this._asset);
9500
9957
  if (!asset) {
@@ -9504,7 +9961,14 @@ class ModelElement extends AsyncElement {
9504
9961
  this._instantiate(asset.resource);
9505
9962
  }
9506
9963
  else {
9507
- asset.once('load', () => {
9964
+ // The generation is re-checked even though a superseded handler is detached: the
9965
+ // detach relies on how the engine's event emitter treats removal, while the check
9966
+ // holds on its own.
9967
+ this._loadHandle = asset.once('load', () => {
9968
+ this._loadHandle = null;
9969
+ if (generation !== this._loadGeneration) {
9970
+ return;
9971
+ }
9508
9972
  this._instantiate(asset.resource);
9509
9973
  });
9510
9974
  app.assets.load(asset);
@@ -9606,10 +10070,17 @@ class SceneElement extends AsyncElement {
9606
10070
  return;
9607
10071
  }
9608
10072
  this._scene = app.scene;
9609
- this.updateSceneSettings();
10073
+ this._updateSceneSettings();
9610
10074
  this._onReady();
9611
10075
  }
9612
- updateSceneSettings() {
10076
+ disconnectedCallback() {
10077
+ // The scene belongs to the application, and removing this element - or the <pc-app>
10078
+ // above it, which disconnects first - parts the two. Re-arm readiness so a re-inserted
10079
+ // element announces the scene it acquires then, not the one it lost here.
10080
+ this._scene = null;
10081
+ this._resetReady();
10082
+ }
10083
+ _updateSceneSettings() {
9613
10084
  if (this._scene) {
9614
10085
  this._scene.fog.type = this._fog;
9615
10086
  this._scene.fog.color = this._fogColor;
@@ -9771,19 +10242,38 @@ class SkyElement extends AsyncElement {
9771
10242
  _center = new Vec3(0, 0.01, 0);
9772
10243
  _intensity = 1;
9773
10244
  _rotation = new Vec3();
9774
- _level = 0;
10245
+ _mipLevel = 0;
9775
10246
  _lighting = false;
9776
10247
  _scale = new Vec3(100, 100, 100);
9777
10248
  _type = 'infinite';
9778
10249
  _scene = null;
9779
10250
  _appElement = null;
10251
+ /**
10252
+ * Incremented on every new load and on disconnect, and captured by a load when it starts. A
10253
+ * load that resumes from an await or a load callback abandons itself if the value has moved
10254
+ * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
10255
+ */
10256
+ _loadGeneration = 0;
10257
+ /**
10258
+ * The pending asset-load subscription of the current load, if it is waiting for its asset.
10259
+ * Held so that whatever supersedes the load can detach the handler from the asset, rather
10260
+ * than leave it registered until the asset loads (or forever, if it never does).
10261
+ */
10262
+ _loadHandle = null;
9780
10263
  connectedCallback() {
9781
10264
  this._loadSkybox();
9782
10265
  this._onReady();
9783
10266
  }
9784
10267
  disconnectedCallback() {
10268
+ this._loadGeneration++;
10269
+ this._detachLoadHandler();
9785
10270
  this._unloadSkybox();
9786
10271
  this._appElement = null;
10272
+ this._resetReady();
10273
+ }
10274
+ _detachLoadHandler() {
10275
+ this._loadHandle?.off();
10276
+ this._loadHandle = null;
9787
10277
  }
9788
10278
  _generateSkybox(asset) {
9789
10279
  if (!this._scene)
@@ -9791,10 +10281,17 @@ class SkyElement extends AsyncElement {
9791
10281
  const source = asset.resource;
9792
10282
  const skybox = EnvLighting.generateSkyboxCubemap(source);
9793
10283
  skybox.anisotropy = 4;
10284
+ // This element owns what it generated (see _unloadSkybox) - replacing a skybox from an
10285
+ // earlier load must release it, not orphan it on the GPU
10286
+ this._scene.skybox?.destroy();
9794
10287
  this._scene.skybox = skybox;
9795
10288
  if (this._lighting) {
9796
10289
  const lighting = EnvLighting.generateLightingSource(source);
9797
10290
  const envAtlas = EnvLighting.generateAtlas(lighting);
10291
+ // The lighting source is an intermediate: the atlas is rendered from it and it is
10292
+ // not needed afterwards
10293
+ lighting.destroy();
10294
+ this._scene.envAtlas?.destroy();
9798
10295
  this._scene.envAtlas = envAtlas;
9799
10296
  }
9800
10297
  const layer = this._scene.layers.getLayerById(LAYERID_SKYBOX);
@@ -9805,10 +10302,17 @@ class SkyElement extends AsyncElement {
9805
10302
  this._scene.sky.node.setLocalScale(this._scale);
9806
10303
  this._scene.sky.center = this._center;
9807
10304
  this._scene.skyboxIntensity = this._intensity;
9808
- this._scene.skyboxMip = this._level;
10305
+ this._scene.skyboxMip = this._mipLevel;
9809
10306
  }
9810
10307
  async _loadSkybox() {
10308
+ // Supersede any load already in flight - only the newest load may generate the skybox
10309
+ const generation = ++this._loadGeneration;
10310
+ this._detachLoadHandler();
9811
10311
  const appElement = await this.closestApp?.ready();
10312
+ // The element may have been removed, or another load started, while we waited
10313
+ if (generation !== this._loadGeneration) {
10314
+ return;
10315
+ }
9812
10316
  const app = appElement?.app;
9813
10317
  if (!appElement || !app) {
9814
10318
  return;
@@ -9823,7 +10327,14 @@ class SkyElement extends AsyncElement {
9823
10327
  this._generateSkybox(asset);
9824
10328
  }
9825
10329
  else {
9826
- asset.once('load', () => {
10330
+ // The generation is re-checked even though a superseded handler is detached: the
10331
+ // detach relies on how the engine's event emitter treats removal, while the check
10332
+ // holds on its own.
10333
+ this._loadHandle = asset.once('load', () => {
10334
+ this._loadHandle = null;
10335
+ if (generation !== this._loadGeneration) {
10336
+ return;
10337
+ }
9827
10338
  this._generateSkybox(asset);
9828
10339
  });
9829
10340
  app.assets.load(asset);
@@ -9897,23 +10408,6 @@ class SkyElement extends AsyncElement {
9897
10408
  get intensity() {
9898
10409
  return this._intensity;
9899
10410
  }
9900
- /**
9901
- * Sets the mip level of the skybox.
9902
- * @param value - The mip level.
9903
- */
9904
- set level(value) {
9905
- this._level = value;
9906
- if (this._scene) {
9907
- this._scene.skyboxMip = this._level;
9908
- }
9909
- }
9910
- /**
9911
- * Gets the mip level of the skybox.
9912
- * @returns The mip level.
9913
- */
9914
- get level() {
9915
- return this._level;
9916
- }
9917
10411
  /**
9918
10412
  * Sets whether the skybox is used as a light source.
9919
10413
  * @param value - Whether to use lighting.
@@ -9928,6 +10422,24 @@ class SkyElement extends AsyncElement {
9928
10422
  get lighting() {
9929
10423
  return this._lighting;
9930
10424
  }
10425
+ /**
10426
+ * Sets the mip level of the skybox, where 0 is the sharpest. Raising it selects a blurrier mip,
10427
+ * which is how a skybox is softened without blurring the texture itself.
10428
+ * @param value - The mip level.
10429
+ */
10430
+ set mipLevel(value) {
10431
+ this._mipLevel = value;
10432
+ if (this._scene) {
10433
+ this._scene.skyboxMip = this._mipLevel;
10434
+ }
10435
+ }
10436
+ /**
10437
+ * Gets the mip level of the skybox.
10438
+ * @returns The mip level.
10439
+ */
10440
+ get mipLevel() {
10441
+ return this._mipLevel;
10442
+ }
9931
10443
  /**
9932
10444
  * Sets the Euler rotation of the skybox.
9933
10445
  * @param value - The rotation.
@@ -9984,7 +10496,7 @@ class SkyElement extends AsyncElement {
9984
10496
  return this._type;
9985
10497
  }
9986
10498
  static get observedAttributes() {
9987
- return ['asset', 'center', 'intensity', 'level', 'lighting', 'rotation', 'scale', 'type'];
10499
+ return ['asset', 'center', 'intensity', 'lighting', 'mip-level', 'rotation', 'scale', 'type'];
9988
10500
  }
9989
10501
  attributeChangedCallback(name, _oldValue, newValue) {
9990
10502
  switch (name) {
@@ -9997,12 +10509,12 @@ class SkyElement extends AsyncElement {
9997
10509
  case 'intensity':
9998
10510
  this.intensity = parseNumber(newValue, 1, name);
9999
10511
  break;
10000
- case 'level':
10001
- this.level = parseNumber(newValue, 0, name);
10002
- break;
10003
10512
  case 'lighting':
10004
10513
  this.lighting = parseBool(newValue, false);
10005
10514
  break;
10515
+ case 'mip-level':
10516
+ this.mipLevel = parseNumber(newValue, 0, name);
10517
+ break;
10006
10518
  case 'rotation':
10007
10519
  this.rotation = parseVec3(newValue, Vec3.ZERO, name);
10008
10520
  break;