@playcanvas/web-components 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/app.d.cts +5 -113
  2. package/dist/app.d.ts +5 -113
  3. package/dist/asset-binding.d.cts +1 -0
  4. package/dist/asset-binding.d.ts +1 -0
  5. package/dist/components/anim-clip.d.cts +8 -10
  6. package/dist/components/anim-clip.d.ts +8 -10
  7. package/dist/components/anim-component.d.cts +5 -4
  8. package/dist/components/anim-component.d.ts +5 -4
  9. package/dist/components/audio-listener-component.d.cts +5 -4
  10. package/dist/components/audio-listener-component.d.ts +5 -4
  11. package/dist/components/button-component.d.cts +5 -4
  12. package/dist/components/button-component.d.ts +5 -4
  13. package/dist/components/camera-component.d.cts +5 -4
  14. package/dist/components/camera-component.d.ts +5 -4
  15. package/dist/components/collision-component.d.cts +5 -4
  16. package/dist/components/collision-component.d.ts +5 -4
  17. package/dist/components/component.d.cts +2 -2
  18. package/dist/components/component.d.ts +2 -2
  19. package/dist/components/element-component.d.cts +5 -4
  20. package/dist/components/element-component.d.ts +5 -4
  21. package/dist/components/gsplat-component.d.cts +5 -4
  22. package/dist/components/gsplat-component.d.ts +5 -4
  23. package/dist/components/joint-component.d.cts +5 -4
  24. package/dist/components/joint-component.d.ts +5 -4
  25. package/dist/components/layout-child-component.d.cts +5 -4
  26. package/dist/components/layout-child-component.d.ts +5 -4
  27. package/dist/components/layout-group-component.d.cts +5 -4
  28. package/dist/components/layout-group-component.d.ts +5 -4
  29. package/dist/components/light-component.d.cts +5 -4
  30. package/dist/components/light-component.d.ts +5 -4
  31. package/dist/components/particle-system-component.d.cts +22 -5
  32. package/dist/components/particle-system-component.d.ts +22 -5
  33. package/dist/components/render-component.d.cts +5 -4
  34. package/dist/components/render-component.d.ts +5 -4
  35. package/dist/components/rigid-body-component.d.cts +5 -4
  36. package/dist/components/rigid-body-component.d.ts +5 -4
  37. package/dist/components/screen-component.d.cts +5 -4
  38. package/dist/components/screen-component.d.ts +5 -4
  39. package/dist/components/script-component.d.cts +5 -4
  40. package/dist/components/script-component.d.ts +5 -4
  41. package/dist/components/scroll-view-component.d.cts +5 -4
  42. package/dist/components/scroll-view-component.d.ts +5 -4
  43. package/dist/components/scrollbar-component.d.cts +5 -4
  44. package/dist/components/scrollbar-component.d.ts +5 -4
  45. package/dist/components/sound-component.d.cts +5 -4
  46. package/dist/components/sound-component.d.ts +5 -4
  47. package/dist/custom-elements.json +38 -38
  48. package/dist/entity-reference.d.cts +13 -0
  49. package/dist/entity-reference.d.ts +13 -0
  50. package/dist/material.d.cts +8 -6
  51. package/dist/material.d.ts +8 -6
  52. package/dist/model.d.cts +8 -9
  53. package/dist/model.d.ts +8 -9
  54. package/dist/parse.d.cts +0 -8
  55. package/dist/parse.d.ts +0 -8
  56. package/dist/pointer-controller.d.cts +1 -0
  57. package/dist/pointer-controller.d.ts +1 -0
  58. package/dist/pwc.cjs +1360 -1186
  59. package/dist/pwc.cjs.map +1 -1
  60. package/dist/pwc.js +1360 -1186
  61. package/dist/pwc.js.map +1 -1
  62. package/dist/pwc.min.js +1 -1
  63. package/dist/pwc.min.js.map +1 -1
  64. package/dist/pwc.min.mjs +1 -1
  65. package/dist/pwc.min.mjs.map +1 -1
  66. package/dist/pwc.mjs +1361 -1187
  67. package/dist/pwc.mjs.map +1 -1
  68. package/dist/sky.d.cts +6 -7
  69. package/dist/sky.d.ts +6 -7
  70. package/dist/web-types.json +39 -39
  71. package/package.json +2 -2
  72. package/src/app.ts +16 -451
  73. package/src/asset-binding.ts +141 -0
  74. package/src/components/anim-clip.ts +22 -45
  75. package/src/components/anim-component.ts +10 -6
  76. package/src/components/audio-listener-component.ts +6 -5
  77. package/src/components/button-component.ts +8 -6
  78. package/src/components/camera-component.ts +6 -5
  79. package/src/components/collision-component.ts +6 -5
  80. package/src/components/component.ts +6 -4
  81. package/src/components/element-component.ts +6 -5
  82. package/src/components/gsplat-component.ts +6 -5
  83. package/src/components/joint-component.ts +8 -6
  84. package/src/components/layout-child-component.ts +6 -5
  85. package/src/components/layout-group-component.ts +6 -5
  86. package/src/components/light-component.ts +6 -5
  87. package/src/components/particle-system-component.ts +56 -30
  88. package/src/components/render-component.ts +6 -5
  89. package/src/components/rigid-body-component.ts +6 -5
  90. package/src/components/screen-component.ts +6 -5
  91. package/src/components/script-component.ts +8 -10
  92. package/src/components/scroll-view-component.ts +8 -6
  93. package/src/components/scrollbar-component.ts +8 -6
  94. package/src/components/sound-component.ts +6 -5
  95. package/src/entity-reference.ts +237 -0
  96. package/src/material.ts +25 -29
  97. package/src/model.ts +24 -48
  98. package/src/parse.ts +0 -232
  99. package/src/pointer-controller.ts +550 -0
  100. package/src/sky.ts +19 -30
package/dist/pwc.js CHANGED
@@ -588,14 +588,6 @@
588
588
  * shared frozen constants (`Vec3.ZERO`, `Color.WHITE`) as defaults.
589
589
  * - `parseBool` and `parseTags` take no attribute name, because every value is valid for them and
590
590
  * so they never warn.
591
- *
592
- * `findEntityElement` and `getEntity` are the exceptions: they resolve a reference rather than
593
- * parsing a literal, and return `null` instead of falling back to a default. A reference
594
- * beginning with `#` is a document-wide selector (an element id, or any selector rooted in one);
595
- * anything else is an entity name, resolved lexically through the entity hierarchy first and
596
- * against the document after — never as a selector or an id. They also do not warn - what an
597
- * unresolved reference means depends on the element holding it - so elements report through
598
- * `resolveEntity`, which takes that meaning as parameters.
599
591
  */
600
592
  /**
601
593
  * Splits an attribute value into exactly `count` numeric components. Returns `null` when the
@@ -841,215 +833,10 @@
841
833
  }
842
834
  return new playcanvas.Vec4(components);
843
835
  };
844
- /**
845
- * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
846
- * arbitrary author text, so a lookup must fail to `null`, never throw.
847
- *
848
- * @param selector - The selector to query.
849
- * @returns The matched element, or `null`.
850
- */
851
- const query = (selector) => {
852
- try {
853
- return document.querySelector(selector);
854
- }
855
- catch {
856
- return null;
857
- }
858
- };
859
- /**
860
- * Runs a lookup against one scope, checking the scope element itself before its subtree — a
861
- * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
862
- * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
863
- * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
864
- * lookup must fail to `null`, never throw.
865
- *
866
- * @param scope - The element whose inclusive subtree to search.
867
- * @param selector - The selector to query.
868
- * @returns The matched element, or `null`.
869
- */
870
- const queryScope = (scope, selector) => {
871
- try {
872
- return scope.matches(selector) ? scope : scope.querySelector(selector);
873
- }
874
- catch {
875
- return null;
876
- }
877
- };
878
- /**
879
- * Reads the entity a resolved element is backing, through the `entity` accessor every
880
- * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
881
- *
882
- * @param element - The element to read, or `null`.
883
- * @returns The backing entity, or `null`.
884
- */
885
- const entityOf = (element) => {
886
- return element?.entity ?? null;
887
- };
888
- /**
889
- * The elements that front an entity: what a bare name can resolve to, and the scopes of the
890
- * lexical name lookup.
891
- */
892
- const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'];
893
- /**
894
- * The entity-fronting elements as one selector, for the scope walk.
895
- */
896
- const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
897
- /**
898
- * Resolves a reference string to the element it names. The grammar is closed — every reference
899
- * has exactly one interpretation:
900
- *
901
- * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
902
- * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
903
- * runs for it, so an unusually named entity cannot shadow it.
904
- * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
905
- * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
906
- * never interpreted as a selector or an element id, so adding or renaming elements can never
907
- * change which form it takes.
908
- *
909
- * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
910
- * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
911
- * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
912
- * entities by name — every clone resolves within itself before a document-wide lookup could reach
913
- * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
914
- * scope.
915
- *
916
- * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
917
- * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
918
- * something matches but is not backing an entity (yet, or ever).
919
- *
920
- * @param ref - The reference string to resolve.
921
- * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
922
- * name lookup. Omitted, the name lookup is document-wide only.
923
- * @returns The matched element, or `null`.
924
- * @internal
925
- */
926
- const findEntityElement = (ref, from) => {
927
- if (!ref) {
928
- return null;
929
- }
930
- // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
931
- // '#body' must never shadow the element whose id is 'body'.
932
- if (ref.startsWith('#')) {
933
- return query(ref);
934
- }
935
- // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
936
- // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
937
- const escaped = ref.replace(/["\\]/g, '\\$&');
938
- const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
939
- if (from) {
940
- let scope = from.parentElement?.closest(ENTITY_SCOPES);
941
- while (scope) {
942
- const element = queryScope(scope, nameSelector);
943
- if (element) {
944
- return element;
945
- }
946
- scope = scope.parentElement?.closest(ENTITY_SCOPES);
947
- }
948
- const app = from.parentElement?.closest('pc-app');
949
- if (app) {
950
- const element = queryScope(app, nameSelector);
951
- if (element) {
952
- return element;
953
- }
954
- }
955
- }
956
- return query(nameSelector);
957
- };
958
- /**
959
- * Resolves a reference string to the {@link Entity} backing an entity-fronting element
960
- * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
961
- * through the entity hierarchy first when `from` is supplied — or a document-wide `#` selector
962
- * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
963
- * element (or backing entity) is found.
964
- *
965
- * @param ref - The reference string to resolve.
966
- * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
967
- * name lookup. Omitted, the name lookup is document-wide only.
968
- * @returns The resolved entity, or `null`.
969
- * @internal
970
- */
971
- const getEntity = (ref, from) => {
972
- return entityOf(findEntityElement(ref, from));
973
- };
974
- /**
975
- * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
976
- * have three different fixes: nothing matches (usually a typo), the matched element is not backing
977
- * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
978
- * later can work), or the matched element can never back one (the reference points at the wrong
979
- * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
980
- * element inherits from EntityBaseElement.
981
- *
982
- * @param element - The element the reference matched, or `null` when nothing did.
983
- * @returns The cause, phrased to follow `could not resolve ... -`.
984
- * @internal
985
- */
986
- const unresolvedCause = (element) => {
987
- if (!element) {
988
- return 'nothing in the document matches it';
989
- }
990
- const tag = `<${element.tagName.toLowerCase()}>`;
991
- return 'entity' in element
992
- ? `${tag} matches it but is not backing an entity yet`
993
- : `${tag} matches it but cannot back an entity`;
994
- };
995
- /**
996
- * Builds the migration pointer for a bare reference that names nothing but matches the id of an
997
- * entity-fronting element - it was almost certainly meant as an id, so point at the form that
998
- * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
999
- * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
1000
- * belongs to an element that could never back an entity - suggesting it would only trade this
1001
- * warning for the wrong-target one.
1002
- *
1003
- * @param ref - The unresolved reference.
1004
- * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
1005
- * @returns The advice sentence, or an empty string.
1006
- * @internal
1007
- */
1008
- const idHint = (ref, prefix = '') => {
1009
- const match = !ref.startsWith('#') && document.getElementById(ref);
1010
- return match && 'entity' in match
1011
- ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
1012
- : '';
1013
- };
1014
- /**
1015
- * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
1016
- * the resolving element ({@link findEntityElement} details the order) and warning when a
1017
- * non-empty reference does not resolve - otherwise the reference fails silently, invisible
1018
- * except through the behavior it should have driven. The message names which of the three causes
1019
- * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
1020
- *
1021
- * An empty reference stays silent: it is the unset state of an optional attribute, and on some
1022
- * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
1023
- *
1024
- * @param ref - The reference string to resolve.
1025
- * @param from - The element resolving the reference; scopes the lookup and names the message.
1026
- * @param attribute - The attribute being resolved, for the message.
1027
- * @param consequence - What the unresolved reference means for the element, for the message.
1028
- * @returns The resolved entity, or `null`.
1029
- * @internal
1030
- */
1031
- const resolveEntity = (ref, from, attribute, consequence) => {
1032
- if (!ref) {
1033
- return null;
1034
- }
1035
- const element = findEntityElement(ref, from);
1036
- const entity = entityOf(element);
1037
- if (!entity) {
1038
- let advice = `Assign ${attribute} again once the entity exists.`;
1039
- if (element && !('entity' in element)) {
1040
- advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
1041
- }
1042
- else if (!element) {
1043
- const hint = idHint(ref);
1044
- if (hint) {
1045
- advice = hint;
1046
- }
1047
- }
1048
- console.warn(`${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`);
1049
- }
1050
- return entity;
1051
- };
1052
836
 
837
+ // Keep `export` on these declarations. TypeScript removes the declaration and its inline export
838
+ // when `stripInternal` is enabled. A separate `export { ... }` statement would remain in the
839
+ // generated .d.ts file and refer to a declaration that had been removed.
1053
840
  /**
1054
841
  * The event types whose listeners make an element a hover target. Hover resolution walks past
1055
842
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -1096,88 +883,29 @@
1096
883
  return null;
1097
884
  };
1098
885
  /**
1099
- * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
1100
- * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
1101
- * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
1102
- * so any page rule - however plain - overrides these defaults.
1103
- */
1104
- const ensureBaseStyles = () => {
1105
- const id = 'pc-app-styles';
1106
- if (document.getElementById(id)) {
1107
- return;
1108
- }
1109
- const style = document.createElement('style');
1110
- style.id = id;
1111
- style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
1112
- document.head.appendChild(style);
1113
- };
1114
- /**
1115
- * The AppElement interface provides properties and methods for manipulating
1116
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
1117
- * The AppElement interface also inherits the properties and methods of the
1118
- * {@link HTMLElement} interface.
1119
- *
1120
- * The element is sized like a replaced element such as `<video>`: a block-level box that the
1121
- * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
1122
- * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
1123
- * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
1124
- * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
1125
- *
1126
- * @elementSummary The `<pc-app>` element creates a PlayCanvas application and the canvas it renders
1127
- * into, and is the root of every scene. It holds the `<pc-asset>`, `<pc-material>`, `<pc-wasm>` and
1128
- * `<pc-scene>` elements, and the page's CSS sizes it, as it would a `<video>`.
886
+ * The pointer-input subsystem of a `<pc-app>` element: it owns the engine {@link Picker}, the
887
+ * canvas pointer handlers, and everything between them - mapping browser coordinates into the
888
+ * drawing buffer, selecting the camera, resolving picked nodes to listening elements, tracking
889
+ * hover, and dispatching the synthesized pointer and click events in canvas-event order.
1129
890
  *
1130
- * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
1131
- * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
1132
- * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
1133
- * not bubble.
891
+ * The host drives a small lifecycle: {@link connect} once the application and canvas exist,
892
+ * {@link resize} when the drawing buffer changes size, {@link syncListeners} when a descendant's
893
+ * pointer listeners change, and {@link disconnect} on teardown. Everything else is internal.
1134
894
  *
1135
- * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
1136
- * could be created (for example, a browser with WebGL disabled). `message` names the requested
1137
- * backends and `error` holds the underlying failure. The element never becomes ready
1138
- * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
1139
- * re-inserting it retries the boot with its current attributes. Does not bubble.
895
+ * @internal
1140
896
  */
1141
- class AppElement extends AsyncElement {
897
+ class PointerController {
898
+ _host;
1142
899
  /**
1143
- * The canvas element.
900
+ * Incremented by every connect and disconnect. Async work captures the value when it starts
901
+ * and stops if it has moved on - so a pick or dispatch belonging to an earlier connection
902
+ * can neither keep reading through its destroyed picker nor deliver into a later
903
+ * connection. The field null checks alone cannot tell the two apart once a reconnect has
904
+ * repopulated them.
1144
905
  */
906
+ _generation = 0;
907
+ _app = null;
1145
908
  _canvas = null;
1146
- _alpha = true;
1147
- _backend = 'webgpu';
1148
- _antialias = true;
1149
- _depthBuffer = true;
1150
- _stencilBuffer = true;
1151
- _maxPixelRatio = Infinity;
1152
- _loadingBar = true;
1153
- /**
1154
- * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
1155
- * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
1156
- * cleared on disconnect so a re-connected element boots from its current attributes.
1157
- */
1158
- _optionsLocked = false;
1159
- _bar = null;
1160
- /**
1161
- * Whether the application has created its initial entity hierarchy. Read by EntityElement to
1162
- * decide whether a newly connected element must create its entity itself or leave it to the
1163
- * boot sweep.
1164
- * @internal
1165
- */
1166
- _hierarchyReady = false;
1167
- /**
1168
- * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
1169
- * itself wherever it resumes from an await if the value has moved on — so a boot whose
1170
- * element was removed cannot complete against a torn-down element, and a boot whose element
1171
- * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
1172
- */
1173
- _bootGeneration = 0;
1174
- /**
1175
- * The elements backing this application's entities, keyed by the entity itself. Registered
1176
- * by entity-owning elements at creation (pc-entity, and pc-model for its host) and by
1177
- * NodeElement at binding, and removed when an entity is destroyed or unbound, this joins
1178
- * engine scene nodes back to their owning elements by identity - never by name.
1179
- */
1180
- _entityElements = new Map();
1181
909
  _picker = null;
1182
910
  _hoveredEntity = null;
1183
911
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
@@ -1196,7 +924,7 @@
1196
924
  * which is also what keeps those two canvas listeners attached.
1197
925
  */
1198
926
  _downPicks = new Map();
1199
- /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
927
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
1200
928
  _clickListened = false;
1201
929
  /**
1202
930
  * The previous click's target, time and count, for chaining successive clicks into the
@@ -1205,504 +933,194 @@
1205
933
  _lastClick = null;
1206
934
  /**
1207
935
  * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
1208
- * whose picks resolve in GPU order, not canvas-event order. Replaced on teardown, so a pick
1209
- * that never resolves cannot stall the dispatches of a later boot.
936
+ * whose picks resolve in GPU order, not canvas-event order. Replaced on disconnect, so a
937
+ * pick that never resolves cannot stall the dispatches of a later boot.
1210
938
  */
1211
939
  _dispatchChain = Promise.resolve();
1212
- _app = null;
1213
- _loadProgress = 0;
1214
940
  /**
1215
- * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
1216
- * the application exists, and disconnected on teardown. `null` where ResizeObserver is
1217
- * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
941
+ * @param host - The services the controller reads from its host element.
1218
942
  */
1219
- _resizeObserver = null;
943
+ constructor(host) {
944
+ this._host = host;
945
+ }
1220
946
  /**
1221
- * The PlayCanvas application instance. `null` until the element is ready, and again once it
1222
- * has been removed from the document await {@link whenReady} or the element's `ready()`
1223
- * promise before accessing it.
1224
- * @returns The application instance, or `null`.
947
+ * Creates the picker and the canvas handlers for a booted application, and attaches whatever
948
+ * canvas listeners the tree's current element listeners already need (handlers created from
949
+ * inline attributes when their elements were first upgraded, or listeners carried over from
950
+ * before a re-boot).
951
+ *
952
+ * @param app - The application to pick against.
953
+ * @param canvas - The canvas the application renders into.
1225
954
  */
1226
- get app() {
1227
- return this._app;
955
+ connect(app, canvas) {
956
+ this._generation++;
957
+ this._app = app;
958
+ this._canvas = canvas;
959
+ const { width, height } = app.graphicsDevice;
960
+ this._picker = new playcanvas.Picker(app, width, height);
961
+ // Create bound handlers but don't attach them yet. The move handler is async, so it is
962
+ // wrapped to discard the promise - a listener must not return one.
963
+ const listener = (handler) => {
964
+ return (event) => {
965
+ handler.call(this, event);
966
+ };
967
+ };
968
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
969
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
970
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
971
+ this._pointerHandlers.pointercancel = (event) => {
972
+ this._downPicks.delete(event.pointerId);
973
+ };
974
+ this.syncListeners();
1228
975
  }
1229
976
  /**
1230
- * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
1231
- * preloading begins (and again once the element has been removed from the document), and 1
1232
- * once preloading has finished — including when there was nothing to preload. Read this to
1233
- * initialize a loading UI; subsequent updates arrive via the `progress` event.
1234
- * @returns The preload progress.
977
+ * Detaches the canvas listeners and drops every piece of pointer state, so nothing picked or
978
+ * queued before the teardown can affect a later boot. Safe to call on a controller that was
979
+ * never connected.
1235
980
  */
1236
- get loadProgress() {
1237
- return this._loadProgress;
981
+ disconnect() {
982
+ this._generation++;
983
+ if (this._canvas) {
984
+ Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
985
+ if (handler) {
986
+ this._canvas.removeEventListener(type, handler);
987
+ }
988
+ });
989
+ }
990
+ this._app = null;
991
+ this._canvas = null;
992
+ this._picker = null;
993
+ this._hoveredEntity = null;
994
+ this._pointerHandlers = {
995
+ pointermove: null,
996
+ pointerdown: null,
997
+ pointerup: null,
998
+ pointercancel: null
999
+ };
1000
+ this._downPicks.clear();
1001
+ this._clickListened = false;
1002
+ this._lastClick = null;
1003
+ // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
1004
+ this._dispatchChain = Promise.resolve();
1238
1005
  }
1239
1006
  /**
1240
- * Creates a new AppElement instance.
1007
+ * Resizes the picker to the drawing buffer. The picker must track the buffer, or picks would
1008
+ * land at stale coordinates after a resize.
1241
1009
  *
1242
- * @ignore
1010
+ * @param width - The drawing buffer width.
1011
+ * @param height - The drawing buffer height.
1243
1012
  */
1244
- constructor() {
1245
- super();
1246
- // Track listeners for the synthesized events being added to and removed from descendant
1247
- // entities. Registered once here rather than on every boot - the sync no-ops while there
1248
- // is no canvas, and a re-booted element must not stack a second set.
1249
- SYNTHESIZED_EVENTS.forEach((type) => {
1250
- this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1251
- this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
1252
- });
1013
+ resize(width, height) {
1014
+ this._picker?.resize(width, height);
1253
1015
  }
1254
- async connectedCallback() {
1255
- const generation = ++this._bootGeneration;
1256
- // Installed before the loading bar is created: the bar anchors to this element, which
1257
- // these styles make a positioned block box
1258
- ensureBaseStyles();
1259
- // Created before the first await, so the bar is visible while modules and the graphics
1260
- // device are created, and exists before any disconnect could need to clean it up
1261
- if (this._loadingBar && !this._bar) {
1262
- this._bar = new LoadingBar(this);
1263
- }
1264
- // Upgrade the subtree before reading anything out of it. A subtree cloned from a
1265
- // <template> arrives entirely unupgraded - template content lives in an inert document,
1266
- // where custom element definitions are never looked up - and appending the clone upgrades
1267
- // its elements in tree order, this one before its descendants. The module query below would
1268
- // otherwise find plain HTMLElements with no _getLoadPromise to call, and the boot would die
1269
- // there, leaving the element permanently unready: no canvas, no entities, no application.
1270
- //
1271
- // Upgrading is the fix here rather than skipping whatever has not upgraded, because a
1272
- // <pc-wasm> is the one child that nothing else ever builds on its own behalf - skipping
1273
- // it would drop the wasm module the app asked for, silently and only for cloned apps.
1274
- // Upgrading runs each descendant's connectedCallback synchronously, a few lines earlier
1275
- // than the parser's path runs them but into the same state they see there: no application
1276
- // yet and _hierarchyReady false, so they defer to the sweeps below. A descendant that
1277
- // disconnects this element from there is caught by the generation check after the await,
1278
- // as any other disconnect is. An already-upgraded subtree - every other insertion path -
1279
- // is left completely untouched.
1280
- customElements.upgrade(this);
1281
- // Get all pc-wasm elements that are direct children of the pc-app element
1282
- const wasmElements = this.querySelectorAll(':scope > pc-wasm');
1283
- // Wait for all modules to load
1284
- await Promise.all(Array.from(wasmElements).map((element) => element._getLoadPromise()));
1285
- // The element may have been removed while the modules loaded. Nothing beyond the loading
1286
- // bar exists yet, and disconnectedCallback has already destroyed that.
1287
- if (generation !== this._bootGeneration) {
1016
+ /**
1017
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
1018
+ * detaches the rest. Called whenever a listener connects or disconnects anywhere under the
1019
+ * host element: several synthesized types can need the same canvas listener (enter, leave
1020
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
1021
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
1022
+ * a no-op by EventTarget semantics, so no attach state is kept. Does nothing before
1023
+ * {@link connect} - connecting syncs once the handlers exist.
1024
+ */
1025
+ syncListeners() {
1026
+ const canvas = this._canvas;
1027
+ if (!canvas)
1288
1028
  return;
1029
+ const elements = this._host.pointerTargets();
1030
+ const needed = new Set();
1031
+ for (const type of SYNTHESIZED_EVENTS) {
1032
+ if (elements.some((element) => element._hasListeners(type))) {
1033
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1034
+ }
1289
1035
  }
1290
- // Create and append the canvas, filling the element's content box - the page sizes the
1291
- // element, and everything else follows. touch-action: none keeps touch drags driving the
1292
- // engine's input handlers instead of scrolling the page.
1293
- this._canvas = document.createElement('canvas');
1294
- this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
1295
- this.appendChild(this._canvas);
1296
- // Configure device types based on backend selection
1297
- const backendToDeviceTypes = {
1298
- webgpu: ['webgpu', 'webgl2'], // fallback to webgl2 if webgpu not available
1299
- webgl2: ['webgl2'],
1300
- null: ['null']
1301
- };
1302
- const deviceTypes = backendToDeviceTypes[this._backend] || [];
1303
- this._optionsLocked = true;
1304
- // createGraphicsDevice appends its final null-device fallback to the array in place, so
1305
- // the requested list is captured now for the failure message.
1306
- const requested = deviceTypes.join(', ');
1307
- let device;
1308
- try {
1309
- device = await playcanvas.createGraphicsDevice(this._canvas, {
1310
- // @ts-ignore - alpha needs to be documented
1311
- alpha: this._alpha,
1312
- antialias: this._antialias,
1313
- depth: this._depthBuffer,
1314
- deviceTypes: deviceTypes,
1315
- stencil: this._stencilBuffer
1316
- });
1317
- }
1318
- catch (error) {
1319
- // The element may have been removed while device creation was failing. The teardown
1320
- // has already cleaned up, and the failure belongs to a boot that no longer owns the
1321
- // element.
1322
- if (generation !== this._bootGeneration) {
1036
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1037
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1038
+ if (!handler)
1323
1039
  return;
1040
+ if (needed.has(canvasType)) {
1041
+ canvas.addEventListener(canvasType, handler);
1324
1042
  }
1325
- // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
1326
- // zero - before announcing the failure. Readiness deliberately stays pending: nothing
1327
- // it would announce (the app, the entity hierarchy) exists, so a device-less element
1328
- // joins the documented never-ready cases and the failure surfaces through the error
1329
- // event instead.
1330
- if (this._canvas && this.contains(this._canvas)) {
1331
- this.removeChild(this._canvas);
1043
+ else {
1044
+ canvas.removeEventListener(canvasType, handler);
1332
1045
  }
1333
- this._canvas = null;
1334
- this._bar?.destroy();
1335
- this._bar = null;
1336
- const reason = error instanceof Error ? error.message : String(error);
1337
- const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
1338
- console.error(message, error);
1339
- this.dispatchEvent(new ErrorEvent('error', { message, error }));
1340
- return;
1046
+ });
1047
+ }
1048
+ /**
1049
+ * Resolves the element that owns hover for a picked node: the nearest node up the parent
1050
+ * chain - starting with the node itself - whose element listens for any of the hover event
1051
+ * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1052
+ * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1053
+ * to hover rather than swallowing a listening ancestor's enter/leave pair.
1054
+ *
1055
+ * @param node - The picked node, or `null`.
1056
+ * @returns The hover-owning element, or `null`.
1057
+ */
1058
+ _hoverTarget(node) {
1059
+ while (node !== null) {
1060
+ const element = this._host.elementFromNode(node);
1061
+ if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1062
+ return element;
1063
+ }
1064
+ node = node.parent;
1341
1065
  }
1342
- // The element may have been removed while the device was created. disconnectedCallback
1343
- // has already cleaned up the canvas; the device was created inside the await, so it is
1344
- // this boot's to release.
1345
- if (generation !== this._bootGeneration) {
1346
- device.destroy();
1347
- return;
1348
- }
1349
- // Assigned rather than resolved to a number here: the engine caps against the live
1350
- // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
1351
- // display when a window moves between monitors of differing density.
1352
- device.maxPixelRatio = this._maxPixelRatio;
1353
- const createOptions = new playcanvas.AppOptions();
1354
- createOptions.graphicsDevice = device;
1355
- createOptions.keyboard = new playcanvas.Keyboard(window);
1356
- createOptions.mouse = new playcanvas.Mouse(this._canvas);
1357
- createOptions.elementInput = new playcanvas.ElementInput(this._canvas, {
1358
- useMouse: true,
1359
- useTouch: true
1360
- });
1361
- createOptions.componentSystems = [
1362
- playcanvas.AnimComponentSystem,
1363
- playcanvas.AnimationComponentSystem,
1364
- playcanvas.AudioListenerComponentSystem,
1365
- playcanvas.ButtonComponentSystem,
1366
- playcanvas.CameraComponentSystem,
1367
- playcanvas.CollisionComponentSystem,
1368
- playcanvas.ElementComponentSystem,
1369
- playcanvas.GSplatComponentSystem,
1370
- playcanvas.JointComponentSystem,
1371
- playcanvas.LayoutChildComponentSystem,
1372
- playcanvas.LayoutGroupComponentSystem,
1373
- playcanvas.LightComponentSystem,
1374
- playcanvas.ModelComponentSystem,
1375
- playcanvas.ParticleSystemComponentSystem,
1376
- playcanvas.RenderComponentSystem,
1377
- playcanvas.RigidBodyComponentSystem,
1378
- playcanvas.ScreenComponentSystem,
1379
- playcanvas.ScriptComponentSystem,
1380
- playcanvas.ScrollbarComponentSystem,
1381
- playcanvas.ScrollViewComponentSystem,
1382
- playcanvas.SoundComponentSystem,
1383
- playcanvas.SpriteComponentSystem,
1384
- playcanvas.ZoneComponentSystem
1385
- ];
1386
- createOptions.resourceHandlers = [
1387
- playcanvas.AnimClipHandler,
1388
- playcanvas.AnimationHandler,
1389
- playcanvas.AnimStateGraphHandler,
1390
- playcanvas.AudioHandler,
1391
- playcanvas.BinaryHandler,
1392
- playcanvas.CssHandler,
1393
- playcanvas.ContainerHandler,
1394
- playcanvas.CubemapHandler,
1395
- playcanvas.FolderHandler,
1396
- playcanvas.FontHandler,
1397
- playcanvas.GSplatHandler,
1398
- playcanvas.HierarchyHandler,
1399
- playcanvas.HtmlHandler,
1400
- playcanvas.JsonHandler,
1401
- playcanvas.MaterialHandler,
1402
- playcanvas.ModelHandler,
1403
- playcanvas.RenderHandler,
1404
- playcanvas.ScriptHandler,
1405
- playcanvas.SceneHandler,
1406
- playcanvas.ShaderHandler,
1407
- playcanvas.SpriteHandler,
1408
- playcanvas.TemplateHandler,
1409
- playcanvas.TextHandler,
1410
- playcanvas.TextureAtlasHandler,
1411
- playcanvas.TextureHandler
1412
- ];
1413
- createOptions.soundManager = new playcanvas.SoundManager();
1414
- createOptions.lightmapper = playcanvas.Lightmapper;
1415
- createOptions.batchManager = playcanvas.BatchManager;
1416
- createOptions.xr = playcanvas.XrManager;
1417
- const app = new playcanvas.AppBase(this._canvas);
1418
- this._app = app;
1419
- app.init(createOptions);
1420
- // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1421
- // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1422
- // from the canvas's client size
1423
- app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
1424
- app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
1425
- this._pickerCreate();
1426
- // Track the element's box rather than the window: containers resize without any window
1427
- // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1428
- // ResizeObserver - there, the resolution set above is the only sizing that happens.
1429
- if (typeof ResizeObserver !== 'undefined') {
1430
- this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1431
- this._resizeObserver.observe(this);
1432
- }
1433
- // Get all pc-asset elements that are direct children of the pc-app element
1434
- const assetElements = this.querySelectorAll(':scope > pc-asset');
1435
- for (const assetElement of Array.from(assetElements)) {
1436
- assetElement._createAsset();
1437
- const asset = assetElement.asset;
1438
- if (asset) {
1439
- app.assets.add(asset);
1440
- // Adding a fileless asset (one built purely from data, such as a sprite)
1441
- // completes it synchronously, dispatching the element's load event - whose
1442
- // listeners may have removed this element. Stop before the next addition
1443
- // reaches the destroyed registry, and before orphan entities are created.
1444
- if (generation !== this._bootGeneration) {
1445
- return;
1446
- }
1447
- }
1448
- }
1449
- // Get all pc-material elements that are direct children of the pc-app element
1450
- const materialElements = this.querySelectorAll(':scope > pc-material');
1451
- Array.from(materialElements).forEach((materialElement) => {
1452
- materialElement._createMaterial();
1453
- });
1454
- // Create all entities. pc-model joins the sweep because it owns a host entity of its
1455
- // own; its instantiated content arrives later, beneath that host.
1456
- const ownerElements = this.querySelectorAll('pc-entity, pc-model');
1457
- Array.from(ownerElements).forEach((ownerElement) => {
1458
- ownerElement._createEntity(app);
1459
- });
1460
- // Build hierarchy
1461
- ownerElements.forEach((ownerElement) => {
1462
- ownerElement._buildHierarchy(app);
1463
- });
1464
- // Building the hierarchy dispatched each entity's ready event synchronously, and a
1465
- // listener may have removed the element. The sweep itself degrades safely - destroying
1466
- // the application nulls every element's entity, so the remaining builds no-op - but the
1467
- // teardown's reset must not be overwritten here.
1468
- if (generation !== this._bootGeneration) {
1469
- return;
1470
- }
1471
- this._hierarchyReady = true;
1472
- // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
1473
- // listener must be attached before preload() is called: an asset that is already loaded
1474
- // ticks synchronously inside it.
1475
- const total = app.assets.list({ preload: true }).length;
1476
- let loaded = 0;
1477
- const onPreloadProgress = () => {
1478
- loaded += 1;
1479
- this._loadProgress = loaded / total;
1480
- this._bar?.progress(loaded, total);
1481
- this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
1482
- };
1483
- app.on('preload:progress', onPreloadProgress);
1484
- this._loadProgress = total === 0 ? 1 : 0;
1485
- this._bar?.progress(0, total);
1486
- this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
1487
- // The progress dispatch above ran listeners synchronously, and one may have removed the
1488
- // element. The application is already destroyed - it must not be asked to preload.
1489
- if (generation !== this._bootGeneration) {
1490
- return;
1491
- }
1492
- // Load assets before starting the application
1493
- app.preload(() => {
1494
- // The element may have been removed while assets loaded. The application is already
1495
- // destroyed, so it must not be started — and readiness must not be signaled for a
1496
- // boot that no longer owns the element.
1497
- if (generation !== this._bootGeneration) {
1498
- return;
1499
- }
1500
- // Scope the counter to this preload pass, so a later app.preload() call by user code
1501
- // cannot push `loaded` past `total`
1502
- app.off('preload:progress', onPreloadProgress);
1503
- this._loadProgress = 1;
1504
- // Start the application
1505
- app.start();
1506
- // Dismiss the bar only once a frame has actually rendered; ready fires before the
1507
- // first rAF tick
1508
- app.once('frameend', () => this._bar?.complete());
1509
- this._onReady();
1510
- });
1511
- }
1512
- disconnectedCallback() {
1513
- // Invalidate any boot still in flight, so it abandons itself when it next resumes
1514
- // instead of completing against a torn-down element.
1515
- this._bootGeneration++;
1516
- this._optionsLocked = false;
1517
- this._pickerDestroy();
1518
- // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1519
- // unregister them - clear() covers any entity the engine no longer reached.
1520
- if (this._app) {
1521
- this._app.destroy();
1522
- this._app = null;
1523
- }
1524
- this._entityElements.clear();
1525
- this._loadProgress = 0;
1526
- this._bar?.destroy();
1527
- this._bar = null;
1528
- // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1529
- // must neither see a hierarchy that no longer exists nor resume against a readiness that
1530
- // no longer holds.
1531
- this._hierarchyReady = false;
1532
- this._resetReady();
1533
- // Stop tracking the element's size
1534
- this._resizeObserver?.disconnect();
1535
- this._resizeObserver = null;
1536
- // Remove the canvas
1537
- if (this._canvas && this.contains(this._canvas)) {
1538
- this.removeChild(this._canvas);
1539
- this._canvas = null;
1066
+ return null;
1067
+ }
1068
+ /**
1069
+ * Like {@link _hoverTarget}, but for one event type: skips elements without a listener for
1070
+ * `type`, so a hit on an unlistened child still reaches a listening ancestor.
1071
+ *
1072
+ * @param node - The picked node, or `null`.
1073
+ * @param type - The pointer event type a listener is required for.
1074
+ * @returns The nearest listening element, or `null`.
1075
+ */
1076
+ _elementWithListener(node, type) {
1077
+ while (node !== null) {
1078
+ const element = this._host.elementFromNode(node);
1079
+ if (element?._hasListeners(type)) {
1080
+ return element;
1081
+ }
1082
+ node = node.parent;
1540
1083
  }
1084
+ return null;
1541
1085
  }
1542
1086
  /**
1543
- * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1544
- * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1545
- * an XR session presents - the session owns the buffer size.
1087
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1088
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1089
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1090
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1091
+ * coordinates correspond to no real geometry.
1092
+ *
1093
+ * @param event - The pointer event to convert.
1094
+ * @param canvas - The canvas the event was dispatched on.
1095
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
1546
1096
  */
1547
- _syncCanvasSize() {
1548
- if (!this.app || this.app.xr?.active) {
1549
- return;
1550
- }
1551
- this.app.updateCanvasSize();
1552
- const { width, height } = this.app.graphicsDevice;
1553
- this._picker?.resize(width, height);
1554
- }
1555
- _pickerCreate() {
1556
- const { width, height } = this.app.graphicsDevice;
1557
- this._picker = new playcanvas.Picker(this.app, width, height);
1558
- // Create bound handlers but don't attach them yet. The move handler is async, so it is
1559
- // wrapped to discard the promise - a listener must not return one.
1560
- const listener = (handler) => {
1561
- return (event) => {
1562
- handler.call(this, event);
1563
- };
1564
- };
1565
- this._pointerHandlers.pointermove = listener(this._onPointerMove);
1566
- this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1567
- this._pointerHandlers.pointerup = listener(this._onPointerUp);
1568
- this._pointerHandlers.pointercancel = (event) => {
1569
- this._downPicks.delete(event.pointerId);
1570
- };
1571
- // Attach canvas listeners for element listeners registered before this boot (e.g.
1572
- // handlers created from inline attributes when their elements were first upgraded, or
1573
- // listeners carried over from before a re-boot)
1574
- this._syncCanvasListeners();
1575
- }
1576
- _pickerDestroy() {
1577
- if (this._canvas) {
1578
- Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
1579
- if (handler) {
1580
- this._canvas.removeEventListener(type, handler);
1581
- }
1582
- });
1097
+ _getPickerCoordinates(event, canvas) {
1098
+ const canvasRect = canvas.getBoundingClientRect();
1099
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
1100
+ return { x: event.clientX, y: event.clientY, mapped: false };
1583
1101
  }
1584
- this._picker = null;
1585
- this._hoveredEntity = null;
1586
- this._pointerHandlers = {
1587
- pointermove: null,
1588
- pointerdown: null,
1589
- pointerup: null,
1590
- pointercancel: null
1102
+ const scaleX = canvas.width / canvasRect.width;
1103
+ const scaleY = canvas.height / canvasRect.height;
1104
+ return {
1105
+ x: (event.clientX - canvasRect.left) * scaleX,
1106
+ y: (event.clientY - canvasRect.top) * scaleY,
1107
+ mapped: true
1591
1108
  };
1592
- this._downPicks.clear();
1593
- this._clickListened = false;
1594
- this._lastClick = null;
1595
- // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
1596
- this._dispatchChain = Promise.resolve();
1597
1109
  }
1598
1110
  /**
1599
- * Registers the element that fronts an entity. Called by EntityElement when it creates its
1600
- * entity, and by NodeElement when it binds one.
1111
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
1112
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1113
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1114
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1115
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1116
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1117
+ * holds nothing there.
1601
1118
  *
1602
- * @param entity - The entity.
1603
- * @param element - The element that fronts it.
1604
- * @internal
1605
- */
1606
- _registerEntityElement(entity, element) {
1607
- this._entityElements.set(entity, element);
1608
- }
1609
- /**
1610
- * Removes the registration for a destroyed entity. Called by EntityElement.
1611
- *
1612
- * @param entity - The entity.
1613
- * @internal
1614
- */
1615
- _unregisterEntityElement(entity) {
1616
- this._entityElements.delete(entity);
1617
- }
1618
- /**
1619
- * Returns the `<pc-entity>`, `<pc-model>` or `<pc-node>` element whose backing entity is
1620
- * `entity`, or `null` if the entity is not fronted by an element of this application - for
1621
- * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1622
- * through the engine API.
1623
- *
1624
- * @param entity - The entity to look up.
1625
- * @returns The element fronting the entity, or `null`.
1626
- */
1627
- elementFromEntity(entity) {
1628
- return this._entityElements.get(entity) ?? null;
1629
- }
1630
- /**
1631
- * Resolves the element that owns hover for a picked node: the nearest node up the parent
1632
- * chain - starting with the node itself - whose element listens for any of the hover event
1633
- * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1634
- * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1635
- * to hover rather than swallowing a listening ancestor's enter/leave pair.
1636
- *
1637
- * @param node - The picked node, or `null`.
1638
- * @returns The hover-owning element, or `null`.
1639
- */
1640
- _hoverTarget(node) {
1641
- while (node !== null) {
1642
- const element = this._entityElements.get(node);
1643
- if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1644
- return element;
1645
- }
1646
- node = node.parent;
1647
- }
1648
- return null;
1649
- }
1650
- /**
1651
- * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1652
- * on an unlistened child still reaches a listening ancestor.
1653
- *
1654
- * @param node - The picked node, or `null`.
1655
- * @param type - The pointer event type a listener is required for.
1656
- * @returns The nearest listening element, or `null`.
1657
- */
1658
- _elementWithListener(node, type) {
1659
- while (node !== null) {
1660
- const element = this._entityElements.get(node);
1661
- if (element?._hasListeners(type)) {
1662
- return element;
1663
- }
1664
- node = node.parent;
1665
- }
1666
- return null;
1667
- }
1668
- /**
1669
- * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1670
- * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1671
- * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1672
- * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1673
- * coordinates correspond to no real geometry.
1674
- *
1675
- * @param event - The pointer event to convert.
1676
- * @param canvas - The canvas the event was dispatched on.
1677
- * @returns The buffer-space coordinates, and whether they were actually mapped.
1678
- */
1679
- _getPickerCoordinates(event, canvas) {
1680
- const canvasRect = canvas.getBoundingClientRect();
1681
- if (canvasRect.width === 0 || canvasRect.height === 0) {
1682
- return { x: event.clientX, y: event.clientY, mapped: false };
1683
- }
1684
- const scaleX = canvas.width / canvasRect.width;
1685
- const scaleY = canvas.height / canvasRect.height;
1686
- return {
1687
- x: (event.clientX - canvasRect.left) * scaleX,
1688
- y: (event.clientY - canvasRect.top) * scaleY,
1689
- mapped: true
1690
- };
1691
- }
1692
- /**
1693
- * Whether a camera's viewport contains the point. A camera renders into its normalized
1694
- * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1695
- * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1696
- * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1697
- * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1698
- * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1699
- * holds nothing there.
1700
- *
1701
- * @param camera - The camera to test.
1702
- * @param x - The x coordinate, in buffer space.
1703
- * @param y - The y coordinate, in buffer space.
1704
- * @param canvas - The canvas the coordinates are relative to.
1705
- * @returns Whether the camera's viewport contains the point.
1119
+ * @param camera - The camera to test.
1120
+ * @param x - The x coordinate, in buffer space.
1121
+ * @param y - The y coordinate, in buffer space.
1122
+ * @param canvas - The canvas the coordinates are relative to.
1123
+ * @returns Whether the camera's viewport contains the point.
1706
1124
  */
1707
1125
  _cameraContains(camera, x, y, canvas) {
1708
1126
  const rect = camera.rect;
@@ -1731,7 +1149,8 @@
1731
1149
  * @returns The graph node under the pointer, or `null` if nothing was hit.
1732
1150
  */
1733
1151
  async _pickNode(event) {
1734
- const app = this.app;
1152
+ const generation = this._generation;
1153
+ const app = this._app;
1735
1154
  const picker = this._picker;
1736
1155
  const canvas = this._canvas;
1737
1156
  if (!app || !picker || !canvas)
@@ -1751,8 +1170,9 @@
1751
1170
  continue;
1752
1171
  picker.prepare(camera, app.scene);
1753
1172
  const selection = await picker.getSelectionAsync(x, y);
1754
- // The element may have disconnected while the read back was in flight.
1755
- if (!this._picker || !this.app)
1173
+ // The host may have disconnected - or disconnected and reconnected - while the read
1174
+ // back was in flight. Either way this pick's connection is gone.
1175
+ if (generation !== this._generation)
1756
1176
  return null;
1757
1177
  if (selection.length > 0) {
1758
1178
  const item = selection[0];
@@ -1767,145 +1187,588 @@
1767
1187
  return null;
1768
1188
  }
1769
1189
  async _onPointerMove(event) {
1770
- if (!this._picker || !this.app)
1190
+ if (!this._picker || !this._app)
1771
1191
  return;
1772
1192
  // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1773
1193
  // newest pick may update the hover state - an older one describes a pointer position the
1774
- // user has already left.
1194
+ // user has already left, and one from an earlier connection describes a scene that no
1195
+ // longer exists.
1196
+ const generation = this._generation;
1775
1197
  const token = ++this._pickToken;
1776
1198
  const node = await this._pickNode(event);
1777
- if (token !== this._pickToken || !this._picker)
1199
+ if (token !== this._pickToken || generation !== this._generation)
1200
+ return;
1201
+ // The hovered element is the nearest one up the node's parent chain with a hover
1202
+ // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1203
+ // type below: having any hover listener selects the target, each event needs its own.
1204
+ const newHoverEntity = this._hoverTarget(node);
1205
+ // Handle enter/leave events
1206
+ if (this._hoveredEntity !== newHoverEntity) {
1207
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1208
+ this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1209
+ }
1210
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1211
+ newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1212
+ }
1213
+ }
1214
+ // Update hover state
1215
+ this._hoveredEntity = newHoverEntity;
1216
+ // Handle pointermove event
1217
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1218
+ newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
1223
+ * canvas event handler - the order of appends is what carries canvas-event order. A step
1224
+ * that rejects is reported and released, so the steps queued behind it still dispatch.
1225
+ *
1226
+ * @param step - The dispatch work to run once every earlier step has finished.
1227
+ */
1228
+ _chainDispatch(step) {
1229
+ this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
1230
+ console.error(error);
1231
+ });
1232
+ }
1233
+ _onPointerDown(event) {
1234
+ if (!this._picker || !this._app)
1235
+ return;
1236
+ const generation = this._generation;
1237
+ // Picks stay concurrent - only the dispatch of the results is serialized
1238
+ const pick = this._pickNode(event);
1239
+ // A click concludes on the matching pointerup, which needs to know what the press
1240
+ // picked. Primary button only - the only button a click can conclude from - and only
1241
+ // while click is listened for, since it is the click mapping that keeps the pointerup
1242
+ // and pointercancel listeners attached to clean the entry up again.
1243
+ if (this._clickListened && event.button === 0) {
1244
+ this._downPicks.set(event.pointerId, pick);
1245
+ }
1246
+ this._chainDispatch(async () => {
1247
+ const node = await pick;
1248
+ if (generation !== this._generation)
1249
+ return; // this press's connection is gone
1250
+ const entityElement = this._elementWithListener(node, 'pointerdown');
1251
+ if (entityElement) {
1252
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1253
+ }
1254
+ });
1255
+ }
1256
+ _onPointerUp(event) {
1257
+ if (!this._picker || !this._app)
1258
+ return;
1259
+ const generation = this._generation;
1260
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
1261
+ // entry is gone before any other event for this pointer can be handled.
1262
+ const downPick = this._downPicks.get(event.pointerId);
1263
+ this._downPicks.delete(event.pointerId);
1264
+ const pick = this._pickNode(event);
1265
+ this._chainDispatch(async () => {
1266
+ const node = await pick;
1267
+ if (generation !== this._generation)
1268
+ return; // this release's connection is gone
1269
+ const entityElement = this._elementWithListener(node, 'pointerup');
1270
+ if (entityElement) {
1271
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1272
+ }
1273
+ });
1274
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1275
+ // what the press and the release picked, for the primary button only. Appended after
1276
+ // the release's own step, so it dispatches after the pointerup that concludes it.
1277
+ if (!downPick || event.button !== 0)
1278
+ return;
1279
+ this._chainDispatch(async () => {
1280
+ // A rejected pick was already reported by the press or release step that awaited it;
1281
+ // here it just means no click can conclude.
1282
+ const picked = await Promise.all([downPick, pick]).catch(() => null);
1283
+ if (!picked || generation !== this._generation)
1284
+ return;
1285
+ const [downNode, upNode] = picked;
1286
+ const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
1287
+ if (clickElement) {
1288
+ const click = new PointerEvent('click', event);
1289
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1290
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
1291
+ // platform chains it (same target, within the double-click window). Overridden
1292
+ // with defineProperty because an event instance used as an init dict cannot have
1293
+ // single fields replaced.
1294
+ const time = performance.now();
1295
+ const last = this._lastClick;
1296
+ const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1297
+ this._lastClick = { element: clickElement, time, count };
1298
+ Object.defineProperty(click, 'detail', { value: count });
1299
+ clickElement.dispatchEvent(click);
1300
+ }
1301
+ });
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
1307
+ * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
1308
+ * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
1309
+ * so any page rule - however plain - overrides these defaults.
1310
+ */
1311
+ const ensureBaseStyles = () => {
1312
+ const id = 'pc-app-styles';
1313
+ if (document.getElementById(id)) {
1314
+ return;
1315
+ }
1316
+ const style = document.createElement('style');
1317
+ style.id = id;
1318
+ style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
1319
+ document.head.appendChild(style);
1320
+ };
1321
+ /**
1322
+ * The AppElement interface provides properties and methods for manipulating
1323
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
1324
+ * The AppElement interface also inherits the properties and methods of the
1325
+ * {@link HTMLElement} interface.
1326
+ *
1327
+ * The element is sized like a replaced element such as `<video>`: a block-level box that the
1328
+ * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
1329
+ * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
1330
+ * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
1331
+ * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
1332
+ *
1333
+ * @elementSummary The `<pc-app>` element creates a PlayCanvas application and the canvas it renders
1334
+ * into, and is the root of every scene. It holds the `<pc-asset>`, `<pc-material>`, `<pc-wasm>` and
1335
+ * `<pc-scene>` elements, and the page's CSS sizes it, as it would a `<video>`.
1336
+ *
1337
+ * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
1338
+ * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
1339
+ * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
1340
+ * not bubble.
1341
+ *
1342
+ * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
1343
+ * could be created (for example, a browser with WebGL disabled). `message` names the requested
1344
+ * backends and `error` holds the underlying failure. The element never becomes ready
1345
+ * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
1346
+ * re-inserting it retries the boot with its current attributes. Does not bubble.
1347
+ */
1348
+ class AppElement extends AsyncElement {
1349
+ /**
1350
+ * The canvas element.
1351
+ */
1352
+ _canvas = null;
1353
+ _alpha = true;
1354
+ _backend = 'webgpu';
1355
+ _antialias = true;
1356
+ _depthBuffer = true;
1357
+ _stencilBuffer = true;
1358
+ _maxPixelRatio = Infinity;
1359
+ _loadingBar = true;
1360
+ /**
1361
+ * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
1362
+ * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
1363
+ * cleared on disconnect so a re-connected element boots from its current attributes.
1364
+ */
1365
+ _optionsLocked = false;
1366
+ _bar = null;
1367
+ /**
1368
+ * Whether the application has created its initial entity hierarchy. Read by EntityElement to
1369
+ * decide whether a newly connected element must create its entity itself or leave it to the
1370
+ * boot sweep.
1371
+ * @internal
1372
+ */
1373
+ _hierarchyReady = false;
1374
+ /**
1375
+ * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
1376
+ * itself wherever it resumes from an await if the value has moved on — so a boot whose
1377
+ * element was removed cannot complete against a torn-down element, and a boot whose element
1378
+ * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
1379
+ */
1380
+ _bootGeneration = 0;
1381
+ /**
1382
+ * The elements backing this application's entities, keyed by the entity itself. Registered
1383
+ * by entity-owning elements at creation (pc-entity, and pc-model for its host) and by
1384
+ * NodeElement at binding, and removed when an entity is destroyed or unbound, this joins
1385
+ * engine scene nodes back to their owning elements by identity - never by name.
1386
+ */
1387
+ _entityElements = new Map();
1388
+ /**
1389
+ * The pointer-input subsystem: the picker, the canvas handlers, and the synthesized-event
1390
+ * dispatch. The element drives its lifecycle (connect on boot, resize with the drawing
1391
+ * buffer, listener syncs, disconnect on teardown) and hands it the two lookups it needs -
1392
+ * everything else about pointer input lives in the controller.
1393
+ */
1394
+ _pointer = new PointerController({
1395
+ elementFromNode: (node) => this._entityElements.get(node) ?? null,
1396
+ pointerTargets: () => Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'))
1397
+ });
1398
+ _app = null;
1399
+ _loadProgress = 0;
1400
+ /**
1401
+ * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
1402
+ * the application exists, and disconnected on teardown. `null` where ResizeObserver is
1403
+ * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
1404
+ */
1405
+ _resizeObserver = null;
1406
+ /**
1407
+ * The PlayCanvas application instance. `null` until the element is ready, and again once it
1408
+ * has been removed from the document — await {@link whenReady} or the element's `ready()`
1409
+ * promise before accessing it.
1410
+ * @returns The application instance, or `null`.
1411
+ */
1412
+ get app() {
1413
+ return this._app;
1414
+ }
1415
+ /**
1416
+ * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
1417
+ * preloading begins (and again once the element has been removed from the document), and 1
1418
+ * once preloading has finished — including when there was nothing to preload. Read this to
1419
+ * initialize a loading UI; subsequent updates arrive via the `progress` event.
1420
+ * @returns The preload progress.
1421
+ */
1422
+ get loadProgress() {
1423
+ return this._loadProgress;
1424
+ }
1425
+ /**
1426
+ * Creates a new AppElement instance.
1427
+ *
1428
+ * @ignore
1429
+ */
1430
+ constructor() {
1431
+ super();
1432
+ // Track listeners for the synthesized events being added to and removed from descendant
1433
+ // entities. Registered once here rather than on every boot - the sync no-ops while there
1434
+ // is no canvas, and a re-booted element must not stack a second set.
1435
+ SYNTHESIZED_EVENTS.forEach((type) => {
1436
+ this.addEventListener(`${type}:connect`, () => this._pointer.syncListeners());
1437
+ this.addEventListener(`${type}:disconnect`, () => this._pointer.syncListeners());
1438
+ });
1439
+ }
1440
+ async connectedCallback() {
1441
+ const generation = ++this._bootGeneration;
1442
+ // Installed before the loading bar is created: the bar anchors to this element, which
1443
+ // these styles make a positioned block box
1444
+ ensureBaseStyles();
1445
+ // Created before the first await, so the bar is visible while modules and the graphics
1446
+ // device are created, and exists before any disconnect could need to clean it up
1447
+ if (this._loadingBar && !this._bar) {
1448
+ this._bar = new LoadingBar(this);
1449
+ }
1450
+ // Upgrade the subtree before reading anything out of it. A subtree cloned from a
1451
+ // <template> arrives entirely unupgraded - template content lives in an inert document,
1452
+ // where custom element definitions are never looked up - and appending the clone upgrades
1453
+ // its elements in tree order, this one before its descendants. The module query below would
1454
+ // otherwise find plain HTMLElements with no _getLoadPromise to call, and the boot would die
1455
+ // there, leaving the element permanently unready: no canvas, no entities, no application.
1456
+ //
1457
+ // Upgrading is the fix here rather than skipping whatever has not upgraded, because a
1458
+ // <pc-wasm> is the one child that nothing else ever builds on its own behalf - skipping
1459
+ // it would drop the wasm module the app asked for, silently and only for cloned apps.
1460
+ // Upgrading runs each descendant's connectedCallback synchronously, a few lines earlier
1461
+ // than the parser's path runs them but into the same state they see there: no application
1462
+ // yet and _hierarchyReady false, so they defer to the sweeps below. A descendant that
1463
+ // disconnects this element from there is caught by the generation check after the await,
1464
+ // as any other disconnect is. An already-upgraded subtree - every other insertion path -
1465
+ // is left completely untouched.
1466
+ customElements.upgrade(this);
1467
+ // Get all pc-wasm elements that are direct children of the pc-app element
1468
+ const wasmElements = this.querySelectorAll(':scope > pc-wasm');
1469
+ // Wait for all modules to load
1470
+ await Promise.all(Array.from(wasmElements).map((element) => element._getLoadPromise()));
1471
+ // The element may have been removed while the modules loaded. Nothing beyond the loading
1472
+ // bar exists yet, and disconnectedCallback has already destroyed that.
1473
+ if (generation !== this._bootGeneration) {
1474
+ return;
1475
+ }
1476
+ // Create and append the canvas, filling the element's content box - the page sizes the
1477
+ // element, and everything else follows. touch-action: none keeps touch drags driving the
1478
+ // engine's input handlers instead of scrolling the page.
1479
+ this._canvas = document.createElement('canvas');
1480
+ this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
1481
+ this.appendChild(this._canvas);
1482
+ // Configure device types based on backend selection
1483
+ const backendToDeviceTypes = {
1484
+ webgpu: ['webgpu', 'webgl2'], // fallback to webgl2 if webgpu not available
1485
+ webgl2: ['webgl2'],
1486
+ null: ['null']
1487
+ };
1488
+ const deviceTypes = backendToDeviceTypes[this._backend] || [];
1489
+ this._optionsLocked = true;
1490
+ // createGraphicsDevice appends its final null-device fallback to the array in place, so
1491
+ // the requested list is captured now for the failure message.
1492
+ const requested = deviceTypes.join(', ');
1493
+ let device;
1494
+ try {
1495
+ device = await playcanvas.createGraphicsDevice(this._canvas, {
1496
+ // @ts-ignore - alpha needs to be documented
1497
+ alpha: this._alpha,
1498
+ antialias: this._antialias,
1499
+ depth: this._depthBuffer,
1500
+ deviceTypes: deviceTypes,
1501
+ stencil: this._stencilBuffer
1502
+ });
1503
+ }
1504
+ catch (error) {
1505
+ // The element may have been removed while device creation was failing. The teardown
1506
+ // has already cleaned up, and the failure belongs to a boot that no longer owns the
1507
+ // element.
1508
+ if (generation !== this._bootGeneration) {
1509
+ return;
1510
+ }
1511
+ // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
1512
+ // zero - before announcing the failure. Readiness deliberately stays pending: nothing
1513
+ // it would announce (the app, the entity hierarchy) exists, so a device-less element
1514
+ // joins the documented never-ready cases and the failure surfaces through the error
1515
+ // event instead.
1516
+ if (this._canvas && this.contains(this._canvas)) {
1517
+ this.removeChild(this._canvas);
1518
+ }
1519
+ this._canvas = null;
1520
+ this._bar?.destroy();
1521
+ this._bar = null;
1522
+ const reason = error instanceof Error ? error.message : String(error);
1523
+ const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
1524
+ console.error(message, error);
1525
+ this.dispatchEvent(new ErrorEvent('error', { message, error }));
1526
+ return;
1527
+ }
1528
+ // The element may have been removed while the device was created. disconnectedCallback
1529
+ // has already cleaned up the canvas; the device was created inside the await, so it is
1530
+ // this boot's to release.
1531
+ if (generation !== this._bootGeneration) {
1532
+ device.destroy();
1533
+ return;
1534
+ }
1535
+ // Assigned rather than resolved to a number here: the engine caps against the live
1536
+ // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
1537
+ // display when a window moves between monitors of differing density.
1538
+ device.maxPixelRatio = this._maxPixelRatio;
1539
+ const createOptions = new playcanvas.AppOptions();
1540
+ createOptions.graphicsDevice = device;
1541
+ createOptions.keyboard = new playcanvas.Keyboard(window);
1542
+ createOptions.mouse = new playcanvas.Mouse(this._canvas);
1543
+ createOptions.elementInput = new playcanvas.ElementInput(this._canvas, {
1544
+ useMouse: true,
1545
+ useTouch: true
1546
+ });
1547
+ createOptions.componentSystems = [
1548
+ playcanvas.AnimComponentSystem,
1549
+ playcanvas.AnimationComponentSystem,
1550
+ playcanvas.AudioListenerComponentSystem,
1551
+ playcanvas.ButtonComponentSystem,
1552
+ playcanvas.CameraComponentSystem,
1553
+ playcanvas.CollisionComponentSystem,
1554
+ playcanvas.ElementComponentSystem,
1555
+ playcanvas.GSplatComponentSystem,
1556
+ playcanvas.JointComponentSystem,
1557
+ playcanvas.LayoutChildComponentSystem,
1558
+ playcanvas.LayoutGroupComponentSystem,
1559
+ playcanvas.LightComponentSystem,
1560
+ playcanvas.ModelComponentSystem,
1561
+ playcanvas.ParticleSystemComponentSystem,
1562
+ playcanvas.RenderComponentSystem,
1563
+ playcanvas.RigidBodyComponentSystem,
1564
+ playcanvas.ScreenComponentSystem,
1565
+ playcanvas.ScriptComponentSystem,
1566
+ playcanvas.ScrollbarComponentSystem,
1567
+ playcanvas.ScrollViewComponentSystem,
1568
+ playcanvas.SoundComponentSystem,
1569
+ playcanvas.SpriteComponentSystem,
1570
+ playcanvas.ZoneComponentSystem
1571
+ ];
1572
+ createOptions.resourceHandlers = [
1573
+ playcanvas.AnimClipHandler,
1574
+ playcanvas.AnimationHandler,
1575
+ playcanvas.AnimStateGraphHandler,
1576
+ playcanvas.AudioHandler,
1577
+ playcanvas.BinaryHandler,
1578
+ playcanvas.CssHandler,
1579
+ playcanvas.ContainerHandler,
1580
+ playcanvas.CubemapHandler,
1581
+ playcanvas.FolderHandler,
1582
+ playcanvas.FontHandler,
1583
+ playcanvas.GSplatHandler,
1584
+ playcanvas.HierarchyHandler,
1585
+ playcanvas.HtmlHandler,
1586
+ playcanvas.JsonHandler,
1587
+ playcanvas.MaterialHandler,
1588
+ playcanvas.ModelHandler,
1589
+ playcanvas.RenderHandler,
1590
+ playcanvas.ScriptHandler,
1591
+ playcanvas.SceneHandler,
1592
+ playcanvas.ShaderHandler,
1593
+ playcanvas.SpriteHandler,
1594
+ playcanvas.TemplateHandler,
1595
+ playcanvas.TextHandler,
1596
+ playcanvas.TextureAtlasHandler,
1597
+ playcanvas.TextureHandler
1598
+ ];
1599
+ createOptions.soundManager = new playcanvas.SoundManager();
1600
+ createOptions.lightmapper = playcanvas.Lightmapper;
1601
+ createOptions.batchManager = playcanvas.BatchManager;
1602
+ createOptions.xr = playcanvas.XrManager;
1603
+ const app = new playcanvas.AppBase(this._canvas);
1604
+ this._app = app;
1605
+ app.init(createOptions);
1606
+ // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1607
+ // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1608
+ // from the canvas's client size
1609
+ app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
1610
+ app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
1611
+ this._pointer.connect(app, this._canvas);
1612
+ // Track the element's box rather than the window: containers resize without any window
1613
+ // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1614
+ // ResizeObserver - there, the resolution set above is the only sizing that happens.
1615
+ if (typeof ResizeObserver !== 'undefined') {
1616
+ this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1617
+ this._resizeObserver.observe(this);
1618
+ }
1619
+ // Get all pc-asset elements that are direct children of the pc-app element
1620
+ const assetElements = this.querySelectorAll(':scope > pc-asset');
1621
+ for (const assetElement of Array.from(assetElements)) {
1622
+ assetElement._createAsset();
1623
+ const asset = assetElement.asset;
1624
+ if (asset) {
1625
+ app.assets.add(asset);
1626
+ // Adding a fileless asset (one built purely from data, such as a sprite)
1627
+ // completes it synchronously, dispatching the element's load event - whose
1628
+ // listeners may have removed this element. Stop before the next addition
1629
+ // reaches the destroyed registry, and before orphan entities are created.
1630
+ if (generation !== this._bootGeneration) {
1631
+ return;
1632
+ }
1633
+ }
1634
+ }
1635
+ // Get all pc-material elements that are direct children of the pc-app element
1636
+ const materialElements = this.querySelectorAll(':scope > pc-material');
1637
+ Array.from(materialElements).forEach((materialElement) => {
1638
+ materialElement._createMaterial();
1639
+ });
1640
+ // Create all entities. pc-model joins the sweep because it owns a host entity of its
1641
+ // own; its instantiated content arrives later, beneath that host.
1642
+ const ownerElements = this.querySelectorAll('pc-entity, pc-model');
1643
+ Array.from(ownerElements).forEach((ownerElement) => {
1644
+ ownerElement._createEntity(app);
1645
+ });
1646
+ // Build hierarchy
1647
+ ownerElements.forEach((ownerElement) => {
1648
+ ownerElement._buildHierarchy(app);
1649
+ });
1650
+ // Building the hierarchy dispatched each entity's ready event synchronously, and a
1651
+ // listener may have removed the element. The sweep itself degrades safely - destroying
1652
+ // the application nulls every element's entity, so the remaining builds no-op - but the
1653
+ // teardown's reset must not be overwritten here.
1654
+ if (generation !== this._bootGeneration) {
1778
1655
  return;
1779
- // The hovered element is the nearest one up the node's parent chain with a hover
1780
- // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1781
- // type below: having any hover listener selects the target, each event needs its own.
1782
- const newHoverEntity = this._hoverTarget(node);
1783
- // Handle enter/leave events
1784
- if (this._hoveredEntity !== newHoverEntity) {
1785
- if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1786
- this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1787
- }
1788
- if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1789
- newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1656
+ }
1657
+ this._hierarchyReady = true;
1658
+ // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
1659
+ // listener must be attached before preload() is called: an asset that is already loaded
1660
+ // ticks synchronously inside it.
1661
+ const total = app.assets.list({ preload: true }).length;
1662
+ let loaded = 0;
1663
+ const onPreloadProgress = () => {
1664
+ loaded += 1;
1665
+ this._loadProgress = loaded / total;
1666
+ this._bar?.progress(loaded, total);
1667
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
1668
+ };
1669
+ app.on('preload:progress', onPreloadProgress);
1670
+ this._loadProgress = total === 0 ? 1 : 0;
1671
+ this._bar?.progress(0, total);
1672
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
1673
+ // The progress dispatch above ran listeners synchronously, and one may have removed the
1674
+ // element. The application is already destroyed - it must not be asked to preload.
1675
+ if (generation !== this._bootGeneration) {
1676
+ return;
1677
+ }
1678
+ // Load assets before starting the application
1679
+ app.preload(() => {
1680
+ // The element may have been removed while assets loaded. The application is already
1681
+ // destroyed, so it must not be started — and readiness must not be signaled for a
1682
+ // boot that no longer owns the element.
1683
+ if (generation !== this._bootGeneration) {
1684
+ return;
1790
1685
  }
1686
+ // Scope the counter to this preload pass, so a later app.preload() call by user code
1687
+ // cannot push `loaded` past `total`
1688
+ app.off('preload:progress', onPreloadProgress);
1689
+ this._loadProgress = 1;
1690
+ // Start the application
1691
+ app.start();
1692
+ // Dismiss the bar only once a frame has actually rendered; ready fires before the
1693
+ // first rAF tick
1694
+ app.once('frameend', () => this._bar?.complete());
1695
+ this._onReady();
1696
+ });
1697
+ }
1698
+ disconnectedCallback() {
1699
+ // Invalidate any boot still in flight, so it abandons itself when it next resumes
1700
+ // instead of completing against a torn-down element.
1701
+ this._bootGeneration++;
1702
+ this._optionsLocked = false;
1703
+ this._pointer.disconnect();
1704
+ // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1705
+ // unregister them - clear() covers any entity the engine no longer reached.
1706
+ if (this._app) {
1707
+ this._app.destroy();
1708
+ this._app = null;
1791
1709
  }
1792
- // Update hover state
1793
- this._hoveredEntity = newHoverEntity;
1794
- // Handle pointermove event
1795
- if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1796
- newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1710
+ this._entityElements.clear();
1711
+ this._loadProgress = 0;
1712
+ this._bar?.destroy();
1713
+ this._bar = null;
1714
+ // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1715
+ // must neither see a hierarchy that no longer exists nor resume against a readiness that
1716
+ // no longer holds.
1717
+ this._hierarchyReady = false;
1718
+ this._resetReady();
1719
+ // Stop tracking the element's size
1720
+ this._resizeObserver?.disconnect();
1721
+ this._resizeObserver = null;
1722
+ // Remove the canvas
1723
+ if (this._canvas && this.contains(this._canvas)) {
1724
+ this.removeChild(this._canvas);
1725
+ this._canvas = null;
1797
1726
  }
1798
1727
  }
1799
1728
  /**
1800
- * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
1801
- * canvas event handler - the order of appends is what carries canvas-event order. A step
1802
- * that rejects is reported and released, so the steps queued behind it still dispatch.
1803
- *
1804
- * @param step - The dispatch work to run once every earlier step has finished.
1729
+ * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1730
+ * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1731
+ * an XR session presents - the session owns the buffer size.
1805
1732
  */
1806
- _chainDispatch(step) {
1807
- this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
1808
- console.error(error);
1809
- });
1810
- }
1811
- _onPointerDown(event) {
1812
- if (!this._picker || !this.app)
1733
+ _syncCanvasSize() {
1734
+ if (!this.app || this.app.xr?.active) {
1813
1735
  return;
1814
- // Picks stay concurrent - only the dispatch of the results is serialized
1815
- const pick = this._pickNode(event);
1816
- // A click concludes on the matching pointerup, which needs to know what the press
1817
- // picked. Primary button only - the only button a click can conclude from - and only
1818
- // while click is listened for, since it is the click mapping that keeps the pointerup
1819
- // and pointercancel listeners attached to clean the entry up again.
1820
- if (this._clickListened && event.button === 0) {
1821
- this._downPicks.set(event.pointerId, pick);
1822
1736
  }
1823
- this._chainDispatch(async () => {
1824
- const node = await pick;
1825
- if (!this._picker)
1826
- return; // the element disconnected while the pick was in flight
1827
- const entityElement = this._elementWithListener(node, 'pointerdown');
1828
- if (entityElement) {
1829
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1830
- }
1831
- });
1737
+ this.app.updateCanvasSize();
1738
+ const { width, height } = this.app.graphicsDevice;
1739
+ this._pointer.resize(width, height);
1832
1740
  }
1833
- _onPointerUp(event) {
1834
- if (!this._picker || !this.app)
1835
- return;
1836
- // The press pick this release may conclude as a click. Claimed synchronously, so the
1837
- // entry is gone before any other event for this pointer can be handled.
1838
- const downPick = this._downPicks.get(event.pointerId);
1839
- this._downPicks.delete(event.pointerId);
1840
- const pick = this._pickNode(event);
1841
- this._chainDispatch(async () => {
1842
- const node = await pick;
1843
- if (!this._picker)
1844
- return; // the element disconnected while the pick was in flight
1845
- const entityElement = this._elementWithListener(node, 'pointerup');
1846
- if (entityElement) {
1847
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1848
- }
1849
- });
1850
- // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1851
- // what the press and the release picked, for the primary button only. Appended after
1852
- // the release's own step, so it dispatches after the pointerup that concludes it.
1853
- if (!downPick || event.button !== 0)
1854
- return;
1855
- this._chainDispatch(async () => {
1856
- // A rejected pick was already reported by the press or release step that awaited it;
1857
- // here it just means no click can conclude.
1858
- const picked = await Promise.all([downPick, pick]).catch(() => null);
1859
- if (!picked || !this._picker)
1860
- return;
1861
- const [downNode, upNode] = picked;
1862
- const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
1863
- if (clickElement) {
1864
- const click = new PointerEvent('click', event);
1865
- // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1866
- // at 0 - but click is exempt: its detail is the click count, chained here as the
1867
- // platform chains it (same target, within the double-click window). Overridden
1868
- // with defineProperty because an event instance used as an init dict cannot have
1869
- // single fields replaced.
1870
- const time = performance.now();
1871
- const last = this._lastClick;
1872
- const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1873
- this._lastClick = { element: clickElement, time, count };
1874
- Object.defineProperty(click, 'detail', { value: count });
1875
- clickElement.dispatchEvent(click);
1876
- }
1877
- });
1741
+ /**
1742
+ * Registers the element that fronts an entity. Called by EntityElement when it creates its
1743
+ * entity, and by NodeElement when it binds one.
1744
+ *
1745
+ * @param entity - The entity.
1746
+ * @param element - The element that fronts it.
1747
+ * @internal
1748
+ */
1749
+ _registerEntityElement(entity, element) {
1750
+ this._entityElements.set(entity, element);
1878
1751
  }
1879
1752
  /**
1880
- * Attaches exactly the canvas listeners the tree's current element listeners need, and
1881
- * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1882
- * this element: several synthesized types can need the same canvas listener (enter, leave
1883
- * and move all ride the move pick; click rides the down/up pair), so one type's removal
1884
- * must not detach a listener another type still uses. Re-attaching an attached listener is
1885
- * a no-op by EventTarget semantics, so no attach state is kept.
1753
+ * Removes the registration for a destroyed entity. Called by EntityElement.
1754
+ *
1755
+ * @param entity - The entity.
1756
+ * @internal
1886
1757
  */
1887
- _syncCanvasListeners() {
1888
- const canvas = this._canvas;
1889
- if (!canvas)
1890
- return; // not booted yet: _pickerCreate syncs once the handlers exist
1891
- const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1892
- const needed = new Set();
1893
- for (const type of SYNTHESIZED_EVENTS) {
1894
- if (elements.some((element) => element._hasListeners(type))) {
1895
- canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1896
- }
1897
- }
1898
- this._clickListened = elements.some((element) => element._hasListeners('click'));
1899
- Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1900
- if (!handler)
1901
- return;
1902
- if (needed.has(canvasType)) {
1903
- canvas.addEventListener(canvasType, handler);
1904
- }
1905
- else {
1906
- canvas.removeEventListener(canvasType, handler);
1907
- }
1908
- });
1758
+ _unregisterEntityElement(entity) {
1759
+ this._entityElements.delete(entity);
1760
+ }
1761
+ /**
1762
+ * Returns the `<pc-entity>`, `<pc-model>` or `<pc-node>` element whose backing entity is
1763
+ * `entity`, or `null` if the entity is not fronted by an element of this application - for
1764
+ * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1765
+ * through the engine API.
1766
+ *
1767
+ * @param entity - The entity to look up.
1768
+ * @returns The element fronting the entity, or `null`.
1769
+ */
1770
+ elementFromEntity(entity) {
1771
+ return this._entityElements.get(entity) ?? null;
1909
1772
  }
1910
1773
  /**
1911
1774
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -3281,31 +3144,133 @@
3281
3144
  else {
3282
3145
  this.srgb = null;
3283
3146
  }
3284
- break;
3147
+ break;
3148
+ }
3149
+ }
3150
+ }
3151
+ customElements.define('pc-asset', AssetElement);
3152
+ /**
3153
+ * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
3154
+ * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
3155
+ * resolves its references here, which is what makes `lazy` mean load on first use without any
3156
+ * consumer having to remember the load. The load is asynchronous - callers observe the asset's
3157
+ * `load` event for the resource.
3158
+ *
3159
+ * @param id - The `id` of the `<pc-asset>` element.
3160
+ * @returns The asset, or `undefined`.
3161
+ * @internal
3162
+ */
3163
+ const useAsset = (id) => {
3164
+ const asset = AssetElement.get(id);
3165
+ // load() ignores an asset that is already loaded or loading, so repeated resolution
3166
+ // costs nothing.
3167
+ if (asset) {
3168
+ asset.registry?.load(asset);
3169
+ }
3170
+ return asset;
3171
+ };
3172
+
3173
+ /**
3174
+ * Watches the asset currently selected by an element.
3175
+ *
3176
+ * An element can change its asset while the old one is still loading, or disconnect before the
3177
+ * load finishes. This class makes sure callbacks from those older loads do nothing. Calling
3178
+ * {@link bind} stops watching the previous asset and starts watching the new one. Calling
3179
+ * {@link cancel} stops watching altogether.
3180
+ *
3181
+ * Asset lookup goes through {@link useAsset}, so selecting a lazy asset starts its load. The
3182
+ * caller still decides what to do with the result, such as creating scene content, reporting an
3183
+ * error, or marking an element ready.
3184
+ *
3185
+ * Reuse one `AssetBinding` for each asset-valued property throughout the element's lifetime. It
3186
+ * is safe for a callback to call `bind` again: the new asset remains active after the callback
3187
+ * returns.
3188
+ *
3189
+ * @internal
3190
+ */
3191
+ class AssetBinding {
3192
+ /**
3193
+ * Each bind or cancel gets a new number. Event handlers remember the number they were created
3194
+ * with and return if it is no longer current. Old listeners are normally removed as well, but
3195
+ * this check also protects against an event that was already in progress when removal
3196
+ * happened.
3197
+ */
3198
+ _generation = 0;
3199
+ _loadHandle = null;
3200
+ _errorHandle = null;
3201
+ _detach() {
3202
+ this._loadHandle?.off();
3203
+ this._loadHandle = null;
3204
+ this._errorHandle?.off();
3205
+ this._errorHandle = null;
3206
+ }
3207
+ /**
3208
+ * Stops watching the current asset and prevents its callbacks from running. The binding can
3209
+ * be used again by calling {@link bind}.
3210
+ */
3211
+ cancel() {
3212
+ this._generation++;
3213
+ this._detach();
3214
+ }
3215
+ /**
3216
+ * Starts watching the asset registered under `id` and stops watching the previous one. A
3217
+ * missing `id` still clears the previous binding. Looking up a lazy asset starts its load.
3218
+ *
3219
+ * If the asset has already loaded successfully, `load` runs before this method returns. An
3220
+ * earlier failure calls `error` immediately when that callback is provided; without one, the
3221
+ * binding waits for a later successful reload. Assets still loading are watched for the same
3222
+ * two outcomes. Once an event is handled, both listeners are removed.
3223
+ *
3224
+ * @param id - The `id` of the `<pc-asset>` element to bind to.
3225
+ * @param callbacks - Functions to call when loading succeeds or fails.
3226
+ * @returns The selected asset, or `undefined` if no asset has this `id`. The caller decides
3227
+ * how to handle a missing asset.
3228
+ */
3229
+ bind(id, callbacks) {
3230
+ const generation = ++this._generation;
3231
+ this._detach();
3232
+ const asset = useAsset(id);
3233
+ if (!asset) {
3234
+ return undefined;
3235
+ }
3236
+ const { error } = callbacks;
3237
+ if (asset.loaded) {
3238
+ // PlayCanvas sets `loaded` after both success and failure, so a resource must also be
3239
+ // present before this counts as success. Use `!= null` deliberately: `undefined` and
3240
+ // `null` both mean there is no resource, while a valid resource can still be falsy
3241
+ // (for example, an empty text file produces '').
3242
+ if (asset.resource != null) {
3243
+ callbacks.load(asset);
3244
+ return asset;
3245
+ }
3246
+ if (error) {
3247
+ error(`asset '${id}' failed to load`);
3248
+ return asset;
3249
+ }
3250
+ }
3251
+ // Old listeners are normally removed by bind or cancel. The number check is a second
3252
+ // safeguard for a late event. Check it before _detach so an old callback cannot remove
3253
+ // the listeners for the current asset. Whichever current event runs first removes both
3254
+ // listeners.
3255
+ this._loadHandle = asset.once('load', () => {
3256
+ if (generation !== this._generation) {
3257
+ return;
3258
+ }
3259
+ this._detach();
3260
+ callbacks.load(asset);
3261
+ });
3262
+ if (error) {
3263
+ this._errorHandle = asset.once('error', (err) => {
3264
+ if (generation !== this._generation) {
3265
+ return;
3266
+ }
3267
+ this._detach();
3268
+ error(err);
3269
+ });
3285
3270
  }
3271
+ return asset;
3286
3272
  }
3287
3273
  }
3288
- customElements.define('pc-asset', AssetElement);
3289
- /**
3290
- * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
3291
- * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
3292
- * resolves its references here, which is what makes `lazy` mean load on first use without any
3293
- * consumer having to remember the load. The load is asynchronous - callers observe the asset's
3294
- * `load` event for the resource.
3295
- *
3296
- * @param id - The `id` of the `<pc-asset>` element.
3297
- * @returns The asset, or `undefined`.
3298
- * @internal
3299
- */
3300
- const useAsset = (id) => {
3301
- const asset = AssetElement.get(id);
3302
- // load() ignores an asset that is already loaded or loading, so repeated resolution
3303
- // costs nothing.
3304
- if (asset) {
3305
- asset.registry?.load(asset);
3306
- }
3307
- return asset;
3308
- };
3309
3274
 
3310
3275
  /**
3311
3276
  * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
@@ -3407,18 +3372,18 @@
3407
3372
  _contentEntity = null;
3408
3373
  /**
3409
3374
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
3410
- * by a load when it starts. A load that resumes from an await or a load callback abandons
3411
- * itself if the value has moved on, so a superseded load can neither instantiate a second
3412
- * content root nor parent one under a host a newer cycle has already replaced.
3375
+ * by a load when it starts. A load that resumes from an await abandons itself if the value
3376
+ * has moved on, so a superseded load can neither instantiate a second content root nor
3377
+ * parent one under a host a newer cycle has already replaced. The asset subscription itself
3378
+ * is guarded by the binding below.
3413
3379
  */
3414
3380
  _loadGeneration = 0;
3415
3381
  /**
3416
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
3417
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
3418
- * leave them registered until the asset settles (or forever, if it never does).
3382
+ * The subscription to the current container asset while it is loading. Whatever supersedes
3383
+ * the load a newer load, a disconnect, the host dying — cancels it, so the asset settling
3384
+ * later cannot deliver to a load that no longer owns the element.
3419
3385
  */
3420
- _loadHandle = null;
3421
- _errorHandle = null;
3386
+ _binding = new AssetBinding();
3422
3387
  /**
3423
3388
  * The root entity of the instantiated model content, parented beneath the host entity.
3424
3389
  * `null` until the container asset has loaded and been instantiated, after a failed load,
@@ -3509,7 +3474,7 @@
3509
3474
  // resets the element. The generation guard comes first so a load suspended on an await
3510
3475
  // cannot resume against the torn-down element.
3511
3476
  this._loadGeneration++;
3512
- this._detachLoadHandlers();
3477
+ this._binding.cancel();
3513
3478
  this._entity?.destroy();
3514
3479
  }
3515
3480
  /**
@@ -3528,16 +3493,10 @@
3528
3493
  */
3529
3494
  _onEntityDestroy(entity) {
3530
3495
  this._loadGeneration++;
3531
- this._detachLoadHandlers();
3496
+ this._binding.cancel();
3532
3497
  this._contentEntity = null;
3533
3498
  super._onEntityDestroy(entity);
3534
3499
  }
3535
- _detachLoadHandlers() {
3536
- this._loadHandle?.off();
3537
- this._loadHandle = null;
3538
- this._errorHandle?.off();
3539
- this._errorHandle = null;
3540
- }
3541
3500
  /**
3542
3501
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
3543
3502
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -3564,7 +3523,7 @@
3564
3523
  this._destroyContent();
3565
3524
  // Supersede any load already in flight - only the newest load may instantiate
3566
3525
  const generation = ++this._loadGeneration;
3567
- this._detachLoadHandlers();
3526
+ this._binding.cancel();
3568
3527
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
3569
3528
  // content. A no-op on first connection, where readiness is still pending.
3570
3529
  this._resetReady();
@@ -3590,39 +3549,23 @@
3590
3549
  this._onReady();
3591
3550
  return;
3592
3551
  }
3593
- const asset = useAsset(this._asset);
3594
- if (!asset) {
3595
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3596
- // silently pending.
3597
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3598
- return;
3599
- }
3600
- if (asset.loaded) {
3601
- this._instantiate(asset.resource);
3602
- }
3603
- else {
3604
- // The generation is re-checked even though a superseded handler is detached: the
3605
- // detach relies on how the engine's event emitter treats removal, while the check
3606
- // holds on its own. Whichever of load/error fires first detaches the other.
3607
- this._loadHandle = asset.once('load', () => {
3608
- this._detachLoadHandlers();
3609
- if (generation !== this._loadGeneration) {
3610
- return;
3611
- }
3612
- this._instantiate(asset.resource);
3613
- });
3614
- this._errorHandle = asset.once('error', (err) => {
3615
- this._detachLoadHandlers();
3616
- if (generation !== this._loadGeneration) {
3617
- return;
3618
- }
3552
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
3553
+ // delivery below is always current - no generation re-check needed in the callbacks.
3554
+ const asset = this._binding.bind(this._asset, {
3555
+ load: ({ resource }) => this._instantiate(resource),
3556
+ error: (err) => {
3619
3557
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
3620
3558
  // readiness means the load settled, not that it succeeded.
3621
3559
  this.dispatchEvent(new ErrorEvent('error', {
3622
3560
  message: err instanceof Error ? err.message : String(err)
3623
3561
  }));
3624
3562
  this._onReady();
3625
- });
3563
+ }
3564
+ });
3565
+ if (!asset) {
3566
+ // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3567
+ // silently pending.
3568
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3626
3569
  }
3627
3570
  }
3628
3571
  /**
@@ -3757,6 +3700,8 @@
3757
3700
  console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
3758
3701
  return;
3759
3702
  }
3703
+ // The name passed by the subclass selects the engine system that creates its T - a
3704
+ // pairing the type system cannot express, so it is asserted this once
3760
3705
  this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
3761
3706
  }
3762
3707
  async _addComponent() {
@@ -4019,6 +3964,9 @@
4019
3964
  */
4020
3965
  _applyRootBone() {
4021
3966
  const component = this.component;
3967
+ if (!component) {
3968
+ return;
3969
+ }
4022
3970
  // A non-null root this element did not assign came through the engine API. A fresh
4023
3971
  // component starts at null, which is always reclaimable.
4024
3972
  if (component.rootBone !== null && component.rootBone !== this._managedRootBone) {
@@ -4091,7 +4039,7 @@
4091
4039
  * declared `clip` selection can apply before any asset has loaded.
4092
4040
  */
4093
4041
  _assignClip(clip) {
4094
- this.component.assignAnimation(clip.name, clip._track ?? playcanvas.AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
4042
+ this.component?.assignAnimation(clip.name, clip._track ?? playcanvas.AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
4095
4043
  }
4096
4044
  /**
4097
4045
  * Validates a clip child and, when valid, assigns its state and starts its track resolution.
@@ -4399,8 +4347,9 @@
4399
4347
  component.playing = true;
4400
4348
  }
4401
4349
  /**
4402
- * Gets the underlying PlayCanvas anim component.
4403
- * @returns The anim component.
4350
+ * Gets the underlying PlayCanvas anim component. `null` until the element is
4351
+ * ready see {@link ComponentElement.component}.
4352
+ * @returns The anim component, or `null`.
4404
4353
  */
4405
4354
  get component() {
4406
4355
  return super.component;
@@ -4549,6 +4498,11 @@
4549
4498
  */
4550
4499
  _animElement = null;
4551
4500
  _asset = '';
4501
+ /**
4502
+ * Watches the current source asset while it loads. Starting a new resolution or
4503
+ * disconnecting cancels it, so a superseded source can never hand its track to the parent.
4504
+ */
4505
+ _binding = new AssetBinding();
4552
4506
  /**
4553
4507
  * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
4554
4508
  * a resume from an await abandons itself if the value has moved on, so a stale callback can
@@ -4556,19 +4510,13 @@
4556
4510
  * own callback.
4557
4511
  */
4558
4512
  _connectionGeneration = 0;
4559
- _errorHandle = null;
4560
4513
  /**
4561
4514
  * Incremented on every track resolution and on disconnect, and captured by a resolution when
4562
- * it starts. A resolution that resumes from an await or an asset callback abandons itself if
4563
- * the value has moved on, so a superseded resolution cannot hand a stale track to the parent.
4515
+ * it starts. A resolution that resumes from an await abandons itself if the value has moved
4516
+ * on, so a superseded resolution cannot hand a stale track to the parent. The asset
4517
+ * subscription itself is guarded by the binding above.
4564
4518
  */
4565
4519
  _loadGeneration = 0;
4566
- /**
4567
- * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
4568
- * Held so that whatever supersedes the resolution can detach the handlers from the asset,
4569
- * rather than leave them registered until the asset settles (or forever, if it never does).
4570
- */
4571
- _loadHandle = null;
4572
4520
  _loop = true;
4573
4521
  _name = '';
4574
4522
  _speed = 1;
@@ -4608,7 +4556,7 @@
4608
4556
  // Invalidate any connectedCallback or track resolution still suspended on an await
4609
4557
  this._connectionGeneration++;
4610
4558
  this._loadGeneration++;
4611
- this._detachLoadHandlers();
4559
+ this._binding.cancel();
4612
4560
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
4613
4561
  // by now. The component itself is null if the whole <pc-app> is being torn down —
4614
4562
  // parents disconnect first and have already removed the component.
@@ -4626,12 +4574,6 @@
4626
4574
  }
4627
4575
  return animElement;
4628
4576
  }
4629
- _detachLoadHandlers() {
4630
- this._loadHandle?.off();
4631
- this._loadHandle = null;
4632
- this._errorHandle?.off();
4633
- this._errorHandle = null;
4634
- }
4635
4577
  /**
4636
4578
  * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
4637
4579
  *
@@ -4657,34 +4599,19 @@
4657
4599
  async _resolveTrack(animElement) {
4658
4600
  this._animElement = animElement;
4659
4601
  const generation = ++this._loadGeneration;
4660
- this._detachLoadHandlers();
4602
+ this._binding.cancel();
4661
4603
  if (this._asset) {
4662
- const asset = useAsset(this._asset);
4604
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
4605
+ // delivery below is always current.
4606
+ const asset = this._binding.bind(this._asset, {
4607
+ load: (loaded) => this._extractTrack(loaded, `asset '${this._asset}'`),
4608
+ error: () => {
4609
+ this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4610
+ }
4611
+ });
4663
4612
  if (!asset) {
4664
4613
  this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
4665
- return;
4666
4614
  }
4667
- if (asset.loaded) {
4668
- this._extractTrack(asset, `asset '${this._asset}'`);
4669
- return;
4670
- }
4671
- // Whichever of load/error fires first detaches the other. The generation is
4672
- // re-checked even though a superseded handler is detached: the detach relies on how
4673
- // the engine's event emitter treats removal, while the check holds on its own.
4674
- this._loadHandle = asset.once('load', () => {
4675
- this._detachLoadHandlers();
4676
- if (generation !== this._loadGeneration) {
4677
- return;
4678
- }
4679
- this._extractTrack(asset, `asset '${this._asset}'`);
4680
- });
4681
- this._errorHandle = asset.once('error', () => {
4682
- this._detachLoadHandlers();
4683
- if (generation !== this._loadGeneration) {
4684
- return;
4685
- }
4686
- this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4687
- });
4688
4615
  return;
4689
4616
  }
4690
4617
  const model = animElement.parentElement;
@@ -4791,99 +4718,321 @@
4791
4718
  this._loop = value;
4792
4719
  this._animElement?._onClipParamsChanged(this);
4793
4720
  }
4794
- /**
4795
- * Gets whether the clip loops.
4796
- * @returns Whether the clip loops.
4797
- */
4798
- get loop() {
4799
- return this._loop;
4721
+ /**
4722
+ * Gets whether the clip loops.
4723
+ * @returns Whether the clip loops.
4724
+ */
4725
+ get loop() {
4726
+ return this._loop;
4727
+ }
4728
+ /**
4729
+ * Sets the name of the clip: the name it is played by, and the track looked up in the
4730
+ * clip's source. Names must be unique within a `<pc-anim>` and must not contain `.`.
4731
+ * @param value - The clip name.
4732
+ */
4733
+ set name(value) {
4734
+ this._name = value;
4735
+ this._warnedInvalid = false;
4736
+ if (this._animElement) {
4737
+ this._resetReady();
4738
+ this._animElement._refreshClips();
4739
+ }
4740
+ }
4741
+ /**
4742
+ * Gets the name of the clip.
4743
+ * @returns The clip name.
4744
+ */
4745
+ get name() {
4746
+ return this._name;
4747
+ }
4748
+ /**
4749
+ * Sets the playback speed of the clip, where negative values play it backwards. Applies
4750
+ * immediately, preserving the playhead. Defaults to 1.
4751
+ * @param value - The playback speed.
4752
+ */
4753
+ set speed(value) {
4754
+ this._speed = value;
4755
+ this._animElement?._onClipParamsChanged(this);
4756
+ }
4757
+ /**
4758
+ * Gets the playback speed of the clip.
4759
+ * @returns The playback speed.
4760
+ */
4761
+ get speed() {
4762
+ return this._speed;
4763
+ }
4764
+ static get observedAttributes() {
4765
+ return ['asset', 'loop', 'name', 'speed'];
4766
+ }
4767
+ attributeChangedCallback(name, _oldValue, newValue) {
4768
+ switch (name) {
4769
+ case 'asset':
4770
+ this.asset = newValue ?? '';
4771
+ break;
4772
+ case 'loop':
4773
+ this.loop = parseBool(newValue, true);
4774
+ break;
4775
+ case 'name':
4776
+ this.name = newValue ?? '';
4777
+ break;
4778
+ case 'speed':
4779
+ this.speed = parseNumber(newValue, 1, name);
4780
+ break;
4781
+ }
4782
+ }
4783
+ }
4784
+ customElements.define('pc-anim-clip', AnimClipElement);
4785
+
4786
+ /**
4787
+ * The AudioListenerComponentElement interface provides properties and methods for manipulating
4788
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-audio-listener/ | `<pc-audio-listener>`} elements.
4789
+ * The AudioListenerComponentElement interface also inherits the properties and methods of the
4790
+ * {@link HTMLElement} interface.
4791
+ *
4792
+ * Engine component: {@link AudioListenerComponent} (`audiolistener`).
4793
+ *
4794
+ * @elementSummary The `<pc-audio-listener>` element makes its entity the point from which
4795
+ * positional sounds are heard, typically the entity holding the `<pc-camera>`. Must be a child of a
4796
+ * `<pc-entity>`, `<pc-model>` or `<pc-node>`.
4797
+ *
4798
+ * @category Components
4799
+ */
4800
+ class AudioListenerComponentElement extends ComponentElement {
4801
+ /** @ignore */
4802
+ constructor() {
4803
+ super('audiolistener');
4804
+ }
4805
+ /**
4806
+ * Gets the underlying PlayCanvas audio listener component. `null` until the element is
4807
+ * ready — see {@link ComponentElement.component}.
4808
+ * @returns The audio listener component, or `null`.
4809
+ */
4810
+ get component() {
4811
+ return super.component;
4812
+ }
4813
+ }
4814
+ customElements.define('pc-audio-listener', AudioListenerComponentElement);
4815
+
4816
+ /**
4817
+ * Resolves entity reference strings against the document. A reference beginning with `#` is a
4818
+ * document-wide selector (an element id, or any selector rooted in one); anything else is an
4819
+ * entity name, resolved lexically through the entity hierarchy first and against the document
4820
+ * after — never as a selector or an id.
4821
+ *
4822
+ * Unlike the attribute parsers in `parse.ts`, these helpers touch the DOM, and `findEntityElement`
4823
+ * and `getEntity` return `null` instead of falling back to a default. They also do not warn -
4824
+ * what an unresolved reference means depends on the element holding it - so elements report
4825
+ * through `resolveEntity`, which takes that meaning as parameters, and `pc-script` builds its own
4826
+ * message from the lower-level pieces.
4827
+ */
4828
+ /**
4829
+ * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
4830
+ * arbitrary author text, so a lookup must fail to `null`, never throw.
4831
+ *
4832
+ * @param selector - The selector to query.
4833
+ * @returns The matched element, or `null`.
4834
+ */
4835
+ const query = (selector) => {
4836
+ try {
4837
+ return document.querySelector(selector);
4838
+ }
4839
+ catch {
4840
+ return null;
4841
+ }
4842
+ };
4843
+ /**
4844
+ * Runs a lookup against one scope, checking the scope element itself before its subtree — a
4845
+ * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
4846
+ * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
4847
+ * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
4848
+ * lookup must fail to `null`, never throw.
4849
+ *
4850
+ * @param scope - The element whose inclusive subtree to search.
4851
+ * @param selector - The selector to query.
4852
+ * @returns The matched element, or `null`.
4853
+ */
4854
+ const queryScope = (scope, selector) => {
4855
+ try {
4856
+ return scope.matches(selector) ? scope : scope.querySelector(selector);
4857
+ }
4858
+ catch {
4859
+ return null;
4860
+ }
4861
+ };
4862
+ /**
4863
+ * Reads the entity a resolved element is backing, through the `entity` accessor every
4864
+ * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
4865
+ *
4866
+ * @param element - The element to read, or `null`.
4867
+ * @returns The backing entity, or `null`.
4868
+ */
4869
+ const entityOf = (element) => {
4870
+ return element?.entity ?? null;
4871
+ };
4872
+ /**
4873
+ * The elements that front an entity: what a bare name can resolve to, and the scopes of the
4874
+ * lexical name lookup.
4875
+ */
4876
+ const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'];
4877
+ /**
4878
+ * The entity-fronting elements as one selector, for the scope walk.
4879
+ */
4880
+ const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
4881
+ /**
4882
+ * Resolves a reference string to the element it names. The grammar is closed — every reference
4883
+ * has exactly one interpretation:
4884
+ *
4885
+ * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
4886
+ * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
4887
+ * runs for it, so an unusually named entity cannot shadow it.
4888
+ * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
4889
+ * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
4890
+ * never interpreted as a selector or an element id, so adding or renaming elements can never
4891
+ * change which form it takes.
4892
+ *
4893
+ * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
4894
+ * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
4895
+ * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
4896
+ * entities by name — every clone resolves within itself before a document-wide lookup could reach
4897
+ * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
4898
+ * scope.
4899
+ *
4900
+ * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
4901
+ * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
4902
+ * something matches but is not backing an entity (yet, or ever).
4903
+ *
4904
+ * @param ref - The reference string to resolve.
4905
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4906
+ * name lookup. Omitted, the name lookup is document-wide only.
4907
+ * @returns The matched element, or `null`.
4908
+ * @internal
4909
+ */
4910
+ const findEntityElement = (ref, from) => {
4911
+ if (!ref) {
4912
+ return null;
4913
+ }
4914
+ // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
4915
+ // '#body' must never shadow the element whose id is 'body'.
4916
+ if (ref.startsWith('#')) {
4917
+ return query(ref);
4800
4918
  }
4801
- /**
4802
- * Sets the name of the clip: the name it is played by, and the track looked up in the
4803
- * clip's source. Names must be unique within a `<pc-anim>` and must not contain `.`.
4804
- * @param value - The clip name.
4805
- */
4806
- set name(value) {
4807
- this._name = value;
4808
- this._warnedInvalid = false;
4809
- if (this._animElement) {
4810
- this._resetReady();
4811
- this._animElement._refreshClips();
4919
+ // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
4920
+ // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
4921
+ const escaped = ref.replace(/["\\]/g, '\\$&');
4922
+ const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
4923
+ if (from) {
4924
+ let scope = from.parentElement?.closest(ENTITY_SCOPES);
4925
+ while (scope) {
4926
+ const element = queryScope(scope, nameSelector);
4927
+ if (element) {
4928
+ return element;
4929
+ }
4930
+ scope = scope.parentElement?.closest(ENTITY_SCOPES);
4812
4931
  }
4813
- }
4814
- /**
4815
- * Gets the name of the clip.
4816
- * @returns The clip name.
4817
- */
4818
- get name() {
4819
- return this._name;
4820
- }
4821
- /**
4822
- * Sets the playback speed of the clip, where negative values play it backwards. Applies
4823
- * immediately, preserving the playhead. Defaults to 1.
4824
- * @param value - The playback speed.
4825
- */
4826
- set speed(value) {
4827
- this._speed = value;
4828
- this._animElement?._onClipParamsChanged(this);
4829
- }
4830
- /**
4831
- * Gets the playback speed of the clip.
4832
- * @returns The playback speed.
4833
- */
4834
- get speed() {
4835
- return this._speed;
4836
- }
4837
- static get observedAttributes() {
4838
- return ['asset', 'loop', 'name', 'speed'];
4839
- }
4840
- attributeChangedCallback(name, _oldValue, newValue) {
4841
- switch (name) {
4842
- case 'asset':
4843
- this.asset = newValue ?? '';
4844
- break;
4845
- case 'loop':
4846
- this.loop = parseBool(newValue, true);
4847
- break;
4848
- case 'name':
4849
- this.name = newValue ?? '';
4850
- break;
4851
- case 'speed':
4852
- this.speed = parseNumber(newValue, 1, name);
4853
- break;
4932
+ const app = from.parentElement?.closest('pc-app');
4933
+ if (app) {
4934
+ const element = queryScope(app, nameSelector);
4935
+ if (element) {
4936
+ return element;
4937
+ }
4854
4938
  }
4855
4939
  }
4856
- }
4857
- customElements.define('pc-anim-clip', AnimClipElement);
4858
-
4940
+ return query(nameSelector);
4941
+ };
4859
4942
  /**
4860
- * The AudioListenerComponentElement interface provides properties and methods for manipulating
4861
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-audio-listener/ | `<pc-audio-listener>`} elements.
4862
- * The AudioListenerComponentElement interface also inherits the properties and methods of the
4863
- * {@link HTMLElement} interface.
4943
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element
4944
+ * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
4945
+ * through the entity hierarchy first when `from` is supplied or a document-wide `#` selector
4946
+ * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
4947
+ * element (or backing entity) is found.
4864
4948
  *
4865
- * Engine component: {@link AudioListenerComponent} (`audiolistener`).
4949
+ * @param ref - The reference string to resolve.
4950
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4951
+ * name lookup. Omitted, the name lookup is document-wide only.
4952
+ * @returns The resolved entity, or `null`.
4953
+ * @internal
4954
+ */
4955
+ const getEntity = (ref, from) => {
4956
+ return entityOf(findEntityElement(ref, from));
4957
+ };
4958
+ /**
4959
+ * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
4960
+ * have three different fixes: nothing matches (usually a typo), the matched element is not backing
4961
+ * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
4962
+ * later can work), or the matched element can never back one (the reference points at the wrong
4963
+ * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
4964
+ * element inherits from EntityBaseElement.
4866
4965
  *
4867
- * @elementSummary The `<pc-audio-listener>` element makes its entity the point from which
4868
- * positional sounds are heard, typically the entity holding the `<pc-camera>`. Must be a child of a
4869
- * `<pc-entity>`, `<pc-model>` or `<pc-node>`.
4966
+ * @param element - The element the reference matched, or `null` when nothing did.
4967
+ * @returns The cause, phrased to follow `could not resolve ... -`.
4968
+ * @internal
4969
+ */
4970
+ const unresolvedCause = (element) => {
4971
+ if (!element) {
4972
+ return 'nothing in the document matches it';
4973
+ }
4974
+ const tag = `<${element.tagName.toLowerCase()}>`;
4975
+ return 'entity' in element
4976
+ ? `${tag} matches it but is not backing an entity yet`
4977
+ : `${tag} matches it but cannot back an entity`;
4978
+ };
4979
+ /**
4980
+ * Builds the migration pointer for a bare reference that names nothing but matches the id of an
4981
+ * entity-fronting element - it was almost certainly meant as an id, so point at the form that
4982
+ * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
4983
+ * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
4984
+ * belongs to an element that could never back an entity - suggesting it would only trade this
4985
+ * warning for the wrong-target one.
4870
4986
  *
4871
- * @category Components
4987
+ * @param ref - The unresolved reference.
4988
+ * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
4989
+ * @returns The advice sentence, or an empty string.
4990
+ * @internal
4872
4991
  */
4873
- class AudioListenerComponentElement extends ComponentElement {
4874
- /** @ignore */
4875
- constructor() {
4876
- super('audiolistener');
4992
+ const idHint = (ref, prefix = '') => {
4993
+ const match = !ref.startsWith('#') && document.getElementById(ref);
4994
+ return match && 'entity' in match
4995
+ ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
4996
+ : '';
4997
+ };
4998
+ /**
4999
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
5000
+ * the resolving element ({@link findEntityElement} details the order) and warning when a
5001
+ * non-empty reference does not resolve - otherwise the reference fails silently, invisible
5002
+ * except through the behavior it should have driven. The message names which of the three causes
5003
+ * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
5004
+ *
5005
+ * An empty reference stays silent: it is the unset state of an optional attribute, and on some
5006
+ * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
5007
+ *
5008
+ * @param ref - The reference string to resolve.
5009
+ * @param from - The element resolving the reference; scopes the lookup and names the message.
5010
+ * @param attribute - The attribute being resolved, for the message.
5011
+ * @param consequence - What the unresolved reference means for the element, for the message.
5012
+ * @returns The resolved entity, or `null`.
5013
+ * @internal
5014
+ */
5015
+ const resolveEntity = (ref, from, attribute, consequence) => {
5016
+ if (!ref) {
5017
+ return null;
4877
5018
  }
4878
- /**
4879
- * Gets the underlying PlayCanvas audio listener component.
4880
- * @returns The audio listener component.
4881
- */
4882
- get component() {
4883
- return super.component;
5019
+ const element = findEntityElement(ref, from);
5020
+ const entity = entityOf(element);
5021
+ if (!entity) {
5022
+ let advice = `Assign ${attribute} again once the entity exists.`;
5023
+ if (element && !('entity' in element)) {
5024
+ advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
5025
+ }
5026
+ else if (!element) {
5027
+ const hint = idHint(ref);
5028
+ if (hint) {
5029
+ advice = hint;
5030
+ }
5031
+ }
5032
+ console.warn(`${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`);
4884
5033
  }
4885
- }
4886
- customElements.define('pc-audio-listener', AudioListenerComponentElement);
5034
+ return entity;
5035
+ };
4887
5036
 
4888
5037
  const transitionModes = new Map([
4889
5038
  ['tint', playcanvas.BUTTON_TRANSITION_MODE_TINT],
@@ -4959,8 +5108,9 @@
4959
5108
  return data;
4960
5109
  }
4961
5110
  /**
4962
- * Gets the underlying PlayCanvas button component.
4963
- * @returns The button component.
5111
+ * Gets the underlying PlayCanvas button component. `null` until the element is
5112
+ * ready see {@link ComponentElement.component}.
5113
+ * @returns The button component, or `null`.
4964
5114
  */
4965
5115
  get component() {
4966
5116
  return super.component;
@@ -5418,8 +5568,9 @@
5418
5568
  }
5419
5569
  }
5420
5570
  /**
5421
- * Gets the underlying PlayCanvas camera component.
5422
- * @returns The camera component.
5571
+ * Gets the underlying PlayCanvas camera component. `null` until the element is
5572
+ * ready see {@link ComponentElement.component}.
5573
+ * @returns The camera component, or `null`.
5423
5574
  */
5424
5575
  get component() {
5425
5576
  return super.component;
@@ -5908,8 +6059,9 @@
5908
6059
  component.renderAsset = asset;
5909
6060
  }
5910
6061
  /**
5911
- * Gets the underlying PlayCanvas collision component.
5912
- * @returns The collision component.
6062
+ * Gets the underlying PlayCanvas collision component. `null` until the element is
6063
+ * ready see {@link ComponentElement.component}.
6064
+ * @returns The collision component, or `null`.
5913
6065
  */
5914
6066
  get component() {
5915
6067
  return super.component;
@@ -6147,8 +6299,9 @@
6147
6299
  return data;
6148
6300
  }
6149
6301
  /**
6150
- * Gets the underlying PlayCanvas element component.
6151
- * @returns The element component.
6302
+ * Gets the underlying PlayCanvas element component. `null` until the element is
6303
+ * ready see {@link ComponentElement.component}.
6304
+ * @returns The element component, or `null`.
6152
6305
  */
6153
6306
  get component() {
6154
6307
  return super.component;
@@ -6925,8 +7078,9 @@
6925
7078
  component.on('break', this._onBreak, this);
6926
7079
  }
6927
7080
  /**
6928
- * Gets the underlying PlayCanvas joint component.
6929
- * @returns The joint component.
7081
+ * Gets the underlying PlayCanvas joint component. `null` until the element is
7082
+ * ready see {@link ComponentElement.component}.
7083
+ * @returns The joint component, or `null`.
6930
7084
  */
6931
7085
  get component() {
6932
7086
  return super.component;
@@ -7652,8 +7806,9 @@
7652
7806
  };
7653
7807
  }
7654
7808
  /**
7655
- * Gets the underlying PlayCanvas layout child component.
7656
- * @returns The layout child component.
7809
+ * Gets the underlying PlayCanvas layout child component. `null` until the element is
7810
+ * ready see {@link ComponentElement.component}.
7811
+ * @returns The layout child component, or `null`.
7657
7812
  */
7658
7813
  get component() {
7659
7814
  return super.component;
@@ -7872,8 +8027,9 @@
7872
8027
  };
7873
8028
  }
7874
8029
  /**
7875
- * Gets the underlying PlayCanvas layout group component.
7876
- * @returns The layout group component.
8030
+ * Gets the underlying PlayCanvas layout group component. `null` until the element is
8031
+ * ready see {@link ComponentElement.component}.
8032
+ * @returns The layout group component, or `null`.
7877
8033
  */
7878
8034
  get component() {
7879
8035
  return super.component;
@@ -8162,8 +8318,9 @@
8162
8318
  };
8163
8319
  }
8164
8320
  /**
8165
- * Gets the underlying PlayCanvas light component.
8166
- * @returns The light component.
8321
+ * Gets the underlying PlayCanvas light component. `null` until the element is
8322
+ * ready see {@link ComponentElement.component}.
8323
+ * @returns The light component, or `null`.
8167
8324
  */
8168
8325
  get component() {
8169
8326
  return super.component;
@@ -8679,36 +8836,68 @@
8679
8836
  */
8680
8837
  class ParticleSystemComponentElement extends ComponentElement {
8681
8838
  _asset = '';
8839
+ /**
8840
+ * The subscription to the current config asset while its load is in flight. Rebinding
8841
+ * supersedes it and disconnect cancels it, so a superseded config — an earlier asset that
8842
+ * finishes loading after its replacement, or a callback left behind by a previous
8843
+ * connection — can never configure the component.
8844
+ */
8845
+ _binding = new AssetBinding();
8682
8846
  /** @ignore */
8683
8847
  constructor() {
8684
8848
  super('particlesystem');
8685
8849
  }
8686
8850
  getInitialComponentData() {
8687
8851
  const asset = useAsset(this._asset);
8688
- // A lazy config has no resource yet - _loadAsset applies it once the load completes
8852
+ // A lazy config has no resource yet - the config binding applies it once the load
8853
+ // completes
8689
8854
  if (!asset || !asset.resource) {
8690
8855
  return {};
8691
8856
  }
8692
- if (asset.resource.colorMapAsset) {
8693
- const id = asset.resource.colorMapAsset;
8694
- const colorMapAsset = useAsset(id)?.id;
8695
- if (colorMapAsset) {
8696
- asset.resource.colorMapAsset = colorMapAsset;
8697
- }
8698
- }
8857
+ this._resolveColorMap(asset.resource);
8699
8858
  return asset.resource;
8700
8859
  }
8860
+ initComponent() {
8861
+ // A loaded config already arrived through getInitialComponentData - the binding is only
8862
+ // needed for a load still in flight. Resolution here also starts a lazy config's load.
8863
+ const asset = useAsset(this._asset);
8864
+ if (asset && !asset.loaded) {
8865
+ this._bindConfig();
8866
+ }
8867
+ }
8868
+ disconnectedCallback() {
8869
+ // The binding dies with the connection, so a config that finishes loading later cannot
8870
+ // configure the component a reconnection creates - that connection binds afresh.
8871
+ this._binding.cancel();
8872
+ super.disconnectedCallback();
8873
+ }
8701
8874
  /**
8702
- * Gets the underlying PlayCanvas particle system component.
8703
- * @returns The particle system component.
8875
+ * Gets the underlying PlayCanvas particle system component. `null` until the element is
8876
+ * ready see {@link ComponentElement.component}.
8877
+ * @returns The particle system component, or `null`.
8704
8878
  */
8705
8879
  get component() {
8706
8880
  return super.component;
8707
8881
  }
8882
+ /**
8883
+ * Rewrites the config's `colorMapAsset` from the `pc-asset` id it is authored with to the
8884
+ * engine asset id the component resolves, starting the texture's load if it is lazy. The
8885
+ * rewrite is in place, so a config applied again — a host cycle, a reconnection — is already
8886
+ * resolved and passes through unchanged.
8887
+ */
8888
+ _resolveColorMap(resource) {
8889
+ if (resource.colorMapAsset) {
8890
+ const colorMapAsset = useAsset(resource.colorMapAsset)?.id;
8891
+ if (colorMapAsset) {
8892
+ resource.colorMapAsset = colorMapAsset;
8893
+ }
8894
+ }
8895
+ }
8708
8896
  applyConfig(resource) {
8709
8897
  if (!this.component) {
8710
8898
  return;
8711
8899
  }
8900
+ this._resolveColorMap(resource);
8712
8901
  // Set all the config properties on the component
8713
8902
  for (const key in resource) {
8714
8903
  if (Object.hasOwn(resource, key)) {
@@ -8716,20 +8905,10 @@
8716
8905
  }
8717
8906
  }
8718
8907
  }
8719
- async _loadAsset() {
8720
- await this.closestApp?.ready();
8721
- const asset = useAsset(this._asset);
8722
- if (!asset) {
8723
- return;
8724
- }
8725
- if (asset.loaded) {
8726
- this.applyConfig(asset.resource);
8727
- }
8728
- else {
8729
- asset.once('load', () => {
8730
- this.applyConfig(asset.resource);
8731
- });
8732
- }
8908
+ _bindConfig() {
8909
+ this._binding.bind(this._asset, {
8910
+ load: (asset) => this.applyConfig(asset.resource)
8911
+ });
8733
8912
  }
8734
8913
  /**
8735
8914
  * Sets the id of the `pc-asset` to use for the model.
@@ -8738,7 +8917,7 @@
8738
8917
  set asset(value) {
8739
8918
  this._asset = value;
8740
8919
  if (this.isConnected) {
8741
- this._loadAsset();
8920
+ this._bindConfig();
8742
8921
  }
8743
8922
  }
8744
8923
  /**
@@ -8955,11 +9134,12 @@
8955
9134
  _useSkybox = true;
8956
9135
  _useTonemap = true;
8957
9136
  /**
8958
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
8959
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
8960
- * element no longer wants.
9137
+ * One asset binding per texture slot, created on first use and kept for the element's
9138
+ * lifetime. A slot's binding is superseded when the slot is reassigned and cancelled when the
9139
+ * element disconnects, so a late-arriving asset can never write a texture the element no
9140
+ * longer wants.
8961
9141
  */
8962
- _mapHandles = new Map();
9142
+ _mapBindings = new Map();
8963
9143
  _updateScheduled = false;
8964
9144
  _glossConflictWarned = false;
8965
9145
  /**
@@ -9086,10 +9266,9 @@
9086
9266
  material.update();
9087
9267
  }
9088
9268
  disconnectedCallback() {
9089
- for (const handle of this._mapHandles.values()) {
9090
- handle.off();
9269
+ for (const binding of this._mapBindings.values()) {
9270
+ binding.cancel();
9091
9271
  }
9092
- this._mapHandles.clear();
9093
9272
  if (this.material) {
9094
9273
  this.material.destroy();
9095
9274
  this.material = null;
@@ -9135,16 +9314,23 @@
9135
9314
  'the roughness-* attributes invert gloss, so the two families contradict each other. Use one or the other.');
9136
9315
  }
9137
9316
  /**
9138
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
9139
- * has not already. An empty id clears the slot.
9317
+ * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when
9318
+ * it has not already. An empty id clears the slot; a slot keeps its current texture while
9319
+ * the new asset loads, and also across a failed load - a later reload can still deliver.
9140
9320
  *
9141
9321
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
9142
9322
  * @param slot - The material property to write.
9143
9323
  */
9144
9324
  _setMap(id, slot) {
9145
- // Drop any load still pending for this slot - its texture is no longer the one we want
9146
- this._mapHandles.get(slot)?.off();
9147
- this._mapHandles.delete(slot);
9325
+ let binding = this._mapBindings.get(slot);
9326
+ if (!binding) {
9327
+ binding = new AssetBinding();
9328
+ this._mapBindings.set(slot, binding);
9329
+ }
9330
+ // Drop any load still pending for this slot - its texture is no longer the one we want.
9331
+ // Cancelled here rather than left to the bind below, which the material-less and
9332
+ // clear-slot returns never reach.
9333
+ binding.cancel();
9148
9334
  if (!this.material)
9149
9335
  return;
9150
9336
  if (!id) {
@@ -9152,17 +9338,9 @@
9152
9338
  this._scheduleUpdate();
9153
9339
  return;
9154
9340
  }
9155
- const asset = useAsset(id);
9156
- if (!asset)
9157
- return;
9158
- if (asset.loaded) {
9159
- this._applyMap(slot, asset.resource);
9160
- return;
9161
- }
9162
- this._mapHandles.set(slot, asset.once('load', () => {
9163
- this._mapHandles.delete(slot);
9164
- this._applyMap(slot, asset.resource);
9165
- }));
9341
+ binding.bind(id, {
9342
+ load: (asset) => this._applyMap(slot, asset.resource)
9343
+ });
9166
9344
  }
9167
9345
  /**
9168
9346
  * @param slot - The material property to write.
@@ -11058,8 +11236,9 @@
11058
11236
  };
11059
11237
  }
11060
11238
  /**
11061
- * Gets the underlying PlayCanvas render component.
11062
- * @returns The render component.
11239
+ * Gets the underlying PlayCanvas render component. `null` until the element is
11240
+ * ready see {@link ComponentElement.component}.
11241
+ * @returns The render component, or `null`.
11063
11242
  */
11064
11243
  get component() {
11065
11244
  return super.component;
@@ -11230,8 +11409,9 @@
11230
11409
  };
11231
11410
  }
11232
11411
  /**
11233
- * Gets the underlying PlayCanvas rigidbody component.
11234
- * @returns The rigidbody component.
11412
+ * Gets the underlying PlayCanvas rigidbody component. `null` until the element is
11413
+ * ready see {@link ComponentElement.component}.
11414
+ * @returns The rigidbody component, or `null`.
11235
11415
  */
11236
11416
  get component() {
11237
11417
  return super.component;
@@ -11410,8 +11590,9 @@
11410
11590
  };
11411
11591
  }
11412
11592
  /**
11413
- * Gets the underlying PlayCanvas screen component.
11414
- * @returns The screen component.
11593
+ * Gets the underlying PlayCanvas screen component. `null` until the element is
11594
+ * ready see {@link ComponentElement.component}.
11595
+ * @returns The screen component, or `null`.
11415
11596
  */
11416
11597
  get component() {
11417
11598
  return super.component;
@@ -11568,8 +11749,9 @@
11568
11749
  return data;
11569
11750
  }
11570
11751
  /**
11571
- * Gets the underlying PlayCanvas scrollbar component.
11572
- * @returns The scrollbar component.
11752
+ * Gets the underlying PlayCanvas scrollbar component. `null` until the element is
11753
+ * ready see {@link ComponentElement.component}.
11754
+ * @returns The scrollbar component, or `null`.
11573
11755
  */
11574
11756
  get component() {
11575
11757
  return super.component;
@@ -11745,8 +11927,9 @@
11745
11927
  return data;
11746
11928
  }
11747
11929
  /**
11748
- * Gets the underlying PlayCanvas scroll view component.
11749
- * @returns The scroll view component.
11930
+ * Gets the underlying PlayCanvas scroll view component. `null` until the element is
11931
+ * ready see {@link ComponentElement.component}.
11932
+ * @returns The scroll view component, or `null`.
11750
11933
  */
11751
11934
  get component() {
11752
11935
  return super.component;
@@ -12898,8 +13081,9 @@
12898
13081
  super.disconnectedCallback?.();
12899
13082
  }
12900
13083
  /**
12901
- * Gets the underlying PlayCanvas script component.
12902
- * @returns The script component.
13084
+ * Gets the underlying PlayCanvas script component. `null` until the element is
13085
+ * ready see {@link ComponentElement.component}.
13086
+ * @returns The script component, or `null`.
12903
13087
  */
12904
13088
  get component() {
12905
13089
  return super.component;
@@ -12945,8 +13129,9 @@
12945
13129
  };
12946
13130
  }
12947
13131
  /**
12948
- * Gets the underlying PlayCanvas sound component.
12949
- * @returns The sound component.
13132
+ * Gets the underlying PlayCanvas sound component. `null` until the element is
13133
+ * ready see {@link ComponentElement.component}.
13134
+ * @returns The sound component, or `null`.
12950
13135
  */
12951
13136
  get component() {
12952
13137
  return super.component;
@@ -13427,8 +13612,9 @@
13427
13612
  };
13428
13613
  }
13429
13614
  /**
13430
- * Gets the underlying PlayCanvas gsplat component.
13431
- * @returns The gsplat component.
13615
+ * Gets the underlying PlayCanvas gsplat component. `null` until the element is
13616
+ * ready see {@link ComponentElement.component}.
13617
+ * @returns The gsplat component, or `null`.
13432
13618
  */
13433
13619
  get component() {
13434
13620
  return super.component;
@@ -14721,31 +14907,27 @@
14721
14907
  _appElement = null;
14722
14908
  /**
14723
14909
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
14724
- * load that resumes from an await or a load callback abandons itself if the value has moved
14725
- * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
14910
+ * load that resumes from an await abandons itself if the value has moved on, so a superseded
14911
+ * load cannot generate a skybox for a scene it no longer configures. The asset subscription
14912
+ * itself is guarded by the binding below.
14726
14913
  */
14727
14914
  _loadGeneration = 0;
14728
14915
  /**
14729
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
14730
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
14731
- * than leave it registered until the asset loads (or forever, if it never does).
14916
+ * Watches the current texture asset while it loads. Starting a new load or disconnecting
14917
+ * cancels it, so a superseded texture can never generate the skybox.
14732
14918
  */
14733
- _loadHandle = null;
14919
+ _binding = new AssetBinding();
14734
14920
  connectedCallback() {
14735
14921
  this._loadSkybox();
14736
14922
  this._onReady();
14737
14923
  }
14738
14924
  disconnectedCallback() {
14739
14925
  this._loadGeneration++;
14740
- this._detachLoadHandler();
14926
+ this._binding.cancel();
14741
14927
  this._unloadSkybox();
14742
14928
  this._appElement = null;
14743
14929
  this._resetReady();
14744
14930
  }
14745
- _detachLoadHandler() {
14746
- this._loadHandle?.off();
14747
- this._loadHandle = null;
14748
- }
14749
14931
  _generateSkybox(asset) {
14750
14932
  if (!this._scene)
14751
14933
  return;
@@ -14777,7 +14959,7 @@
14777
14959
  async _loadSkybox() {
14778
14960
  // Supersede any load already in flight - only the newest load may generate the skybox
14779
14961
  const generation = ++this._loadGeneration;
14780
- this._detachLoadHandler();
14962
+ this._binding.cancel();
14781
14963
  const appElement = await this.closestApp?.ready();
14782
14964
  // The element may have been removed, or another load started, while we waited
14783
14965
  if (generation !== this._loadGeneration) {
@@ -14788,26 +14970,18 @@
14788
14970
  return;
14789
14971
  }
14790
14972
  this._appElement = appElement;
14791
- const asset = useAsset(this._asset);
14792
- if (!asset) {
14973
+ // The scene is only adopted once the reference resolves: an unresolved id must leave the
14974
+ // scene untouched, or this element's teardown would destroy a skybox it never created.
14975
+ // The bind below repeats the resolution, which useAsset documents as free - it cannot
14976
+ // happen after the bind, because a loaded asset delivers before bind returns and
14977
+ // _generateSkybox needs the scene by then.
14978
+ if (!useAsset(this._asset)) {
14793
14979
  return;
14794
14980
  }
14795
14981
  this._scene = app.scene;
14796
- if (asset.loaded) {
14797
- this._generateSkybox(asset);
14798
- }
14799
- else {
14800
- // The generation is re-checked even though a superseded handler is detached: the
14801
- // detach relies on how the engine's event emitter treats removal, while the check
14802
- // holds on its own.
14803
- this._loadHandle = asset.once('load', () => {
14804
- this._loadHandle = null;
14805
- if (generation !== this._loadGeneration) {
14806
- return;
14807
- }
14808
- this._generateSkybox(asset);
14809
- });
14810
- }
14982
+ this._binding.bind(this._asset, {
14983
+ load: (asset) => this._generateSkybox(asset)
14984
+ });
14811
14985
  }
14812
14986
  _unloadSkybox() {
14813
14987
  const scene = this._scene;