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