@playcanvas/web-components 0.19.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 (104) hide show
  1. package/dist/app.d.cts +5 -99
  2. package/dist/app.d.ts +5 -99
  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 +14 -9
  12. package/dist/components/button-component.d.ts +14 -9
  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 +29 -14
  24. package/dist/components/joint-component.d.ts +29 -14
  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 +9 -6
  40. package/dist/components/script-component.d.ts +9 -6
  41. package/dist/components/script-instance.d.cts +14 -6
  42. package/dist/components/script-instance.d.ts +14 -6
  43. package/dist/components/scroll-view-component.d.cts +29 -16
  44. package/dist/components/scroll-view-component.d.ts +29 -16
  45. package/dist/components/scrollbar-component.d.cts +11 -7
  46. package/dist/components/scrollbar-component.d.ts +11 -7
  47. package/dist/components/sound-component.d.cts +5 -4
  48. package/dist/components/sound-component.d.ts +5 -4
  49. package/dist/custom-elements.json +59 -59
  50. package/dist/entity-reference.d.cts +13 -0
  51. package/dist/entity-reference.d.ts +13 -0
  52. package/dist/material.d.cts +8 -6
  53. package/dist/material.d.ts +8 -6
  54. package/dist/model.d.cts +8 -9
  55. package/dist/model.d.ts +8 -9
  56. package/dist/parse.d.cts +0 -3
  57. package/dist/parse.d.ts +0 -3
  58. package/dist/pointer-controller.d.cts +1 -0
  59. package/dist/pointer-controller.d.ts +1 -0
  60. package/dist/pwc.cjs +1085 -633
  61. package/dist/pwc.cjs.map +1 -1
  62. package/dist/pwc.js +1085 -633
  63. package/dist/pwc.js.map +1 -1
  64. package/dist/pwc.min.js +1 -1
  65. package/dist/pwc.min.js.map +1 -1
  66. package/dist/pwc.min.mjs +1 -1
  67. package/dist/pwc.min.mjs.map +1 -1
  68. package/dist/pwc.mjs +1086 -634
  69. package/dist/pwc.mjs.map +1 -1
  70. package/dist/sky.d.cts +6 -7
  71. package/dist/sky.d.ts +6 -7
  72. package/dist/vscode.html-custom-data.json +10 -10
  73. package/dist/web-types.json +58 -58
  74. package/package.json +3 -3
  75. package/src/app.ts +16 -416
  76. package/src/asset-binding.ts +141 -0
  77. package/src/components/anim-clip.ts +22 -45
  78. package/src/components/anim-component.ts +10 -6
  79. package/src/components/audio-listener-component.ts +6 -5
  80. package/src/components/button-component.ts +25 -15
  81. package/src/components/camera-component.ts +6 -5
  82. package/src/components/collision-component.ts +6 -5
  83. package/src/components/component.ts +6 -4
  84. package/src/components/element-component.ts +6 -5
  85. package/src/components/gsplat-component.ts +6 -5
  86. package/src/components/joint-component.ts +36 -20
  87. package/src/components/layout-child-component.ts +6 -5
  88. package/src/components/layout-group-component.ts +6 -5
  89. package/src/components/light-component.ts +6 -5
  90. package/src/components/particle-system-component.ts +56 -30
  91. package/src/components/render-component.ts +6 -5
  92. package/src/components/rigid-body-component.ts +6 -5
  93. package/src/components/screen-component.ts +6 -5
  94. package/src/components/script-component.ts +28 -17
  95. package/src/components/script-instance.ts +14 -6
  96. package/src/components/scroll-view-component.ts +56 -34
  97. package/src/components/scrollbar-component.ts +20 -13
  98. package/src/components/sound-component.ts +6 -5
  99. package/src/entity-reference.ts +237 -0
  100. package/src/material.ts +25 -29
  101. package/src/model.ts +24 -48
  102. package/src/parse.ts +0 -35
  103. package/src/pointer-controller.ts +550 -0
  104. package/src/sky.ts +19 -30
package/dist/pwc.cjs CHANGED
@@ -586,9 +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
- * `getEntity` is the exception: it resolves a reference to a live entity rather than parsing a
591
- * literal, and returns `null` instead of falling back to a default.
592
589
  */
593
590
  /**
594
591
  * Splits an attribute value into exactly `count` numeric components. Returns `null` when the
@@ -834,34 +831,10 @@ const parseVec4 = (value, defaultValue, attribute) => {
834
831
  }
835
832
  return new playcanvas.Vec4(components);
836
833
  };
837
- /**
838
- * Resolves a reference string to the {@link Entity} backing a `<pc-entity>` element. The reference
839
- * can be a CSS selector (e.g. `#my-id`, `pc-entity[name="Foo"]`), a bare element id, or a bare
840
- * entity name. Returns `null` if no matching element (or backing entity) is found.
841
- *
842
- * @param ref - The reference string to resolve.
843
- * @returns The resolved entity, or `null`.
844
- * @internal
845
- */
846
- const getEntity = (ref) => {
847
- if (!ref) {
848
- return null;
849
- }
850
- let element = null;
851
- // Try the reference as a CSS selector. An invalid selector (e.g. a bare name containing
852
- // spaces) throws, in which case we fall back to id/name lookups below.
853
- try {
854
- element = document.querySelector(ref);
855
- }
856
- catch {
857
- element = null;
858
- }
859
- if (!element) {
860
- element = document.getElementById(ref) ?? document.querySelector(`pc-entity[name="${ref}"]`);
861
- }
862
- return element?.entity ?? null;
863
- };
864
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.
865
838
  /**
866
839
  * The event types whose listeners make an element a hover target. Hover resolution walks past
867
840
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -905,8 +878,428 @@ const commonAncestor = (a, b) => {
905
878
  return node;
906
879
  }
907
880
  }
908
- return null;
909
- };
881
+ return null;
882
+ };
883
+ /**
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.
888
+ *
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.
892
+ *
893
+ * @internal
894
+ */
895
+ class PointerController {
896
+ _host;
897
+ /**
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.
903
+ */
904
+ _generation = 0;
905
+ _app = null;
906
+ _canvas = null;
907
+ _picker = null;
908
+ _hoveredEntity = null;
909
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
910
+ _pickToken = 0;
911
+ _pointerHandlers = {
912
+ pointermove: null,
913
+ pointerdown: null,
914
+ pointerup: null,
915
+ pointercancel: null
916
+ };
917
+ /**
918
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
919
+ * may still conclude it. The promise is stored rather than its result, so a release can
920
+ * await a press pick that has not resolved yet. Entries are removed by the matching
921
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
922
+ * which is also what keeps those two canvas listeners attached.
923
+ */
924
+ _downPicks = new Map();
925
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
926
+ _clickListened = false;
927
+ /**
928
+ * The previous click's target, time and count, for chaining successive clicks into the
929
+ * click count that `detail` carries. `null` until a click has fired.
930
+ */
931
+ _lastClick = null;
932
+ /**
933
+ * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
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.
936
+ */
937
+ _dispatchChain = Promise.resolve();
938
+ /**
939
+ * @param host - The services the controller reads from its host element.
940
+ */
941
+ constructor(host) {
942
+ this._host = host;
943
+ }
944
+ /**
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.
952
+ */
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();
973
+ }
974
+ /**
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.
978
+ */
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();
1003
+ }
1004
+ /**
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.
1007
+ *
1008
+ * @param width - The drawing buffer width.
1009
+ * @param height - The drawing buffer height.
1010
+ */
1011
+ resize(width, height) {
1012
+ this._picker?.resize(width, height);
1013
+ }
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)
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
+ }
1033
+ }
1034
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1035
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1036
+ if (!handler)
1037
+ return;
1038
+ if (needed.has(canvasType)) {
1039
+ canvas.addEventListener(canvasType, handler);
1040
+ }
1041
+ else {
1042
+ canvas.removeEventListener(canvasType, handler);
1043
+ }
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;
1063
+ }
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;
1081
+ }
1082
+ return null;
1083
+ }
1084
+ /**
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.
1094
+ */
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 };
1099
+ }
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
1106
+ };
1107
+ }
1108
+ /**
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.
1116
+ *
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.
1122
+ */
1123
+ _cameraContains(camera, x, y, canvas) {
1124
+ const rect = camera.rect;
1125
+ const left = rect.x * canvas.width;
1126
+ const bottom = (1 - rect.y) * canvas.height;
1127
+ const top = bottom - rect.w * canvas.height;
1128
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1129
+ }
1130
+ /**
1131
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1132
+ *
1133
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1134
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1135
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1136
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
1137
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1138
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1139
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
1140
+ *
1141
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1142
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
1143
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1144
+ * async variant works on both backends and does not block the main thread on a GPU read.
1145
+ *
1146
+ * @param event - The pointer event to pick under.
1147
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
1148
+ */
1149
+ async _pickNode(event) {
1150
+ const generation = this._generation;
1151
+ const app = this._app;
1152
+ const picker = this._picker;
1153
+ const canvas = this._canvas;
1154
+ if (!app || !picker || !canvas)
1155
+ return null;
1156
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1157
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
1158
+ // renders last and sits on top. Read through .at() because a pick handler may remove
1159
+ // cameras while an earlier iteration's read back is in flight.
1160
+ const cameras = app.systems.camera?.cameras ?? [];
1161
+ for (let i = cameras.length - 1; i >= 0; i--) {
1162
+ const camera = cameras.at(i);
1163
+ // A camera rendering to a texture is not on the canvas.
1164
+ if (!camera || camera.renderTarget)
1165
+ continue;
1166
+ // Coordinates that could not be mapped cannot be tested for containment.
1167
+ if (mapped && !this._cameraContains(camera, x, y, canvas))
1168
+ continue;
1169
+ picker.prepare(camera, app.scene);
1170
+ const selection = await picker.getSelectionAsync(x, y);
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)
1174
+ return null;
1175
+ if (selection.length > 0) {
1176
+ const item = selection[0];
1177
+ return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1178
+ }
1179
+ // Nothing hit. A camera that clears the color buffer paints its background over
1180
+ // everything beneath it, so the miss is final; one that does not is an overlay
1181
+ // that the cameras beneath show through, so they get their turn.
1182
+ if (camera.clearColorBuffer)
1183
+ return null;
1184
+ }
1185
+ return null;
1186
+ }
1187
+ async _onPointerMove(event) {
1188
+ if (!this._picker || !this._app)
1189
+ return;
1190
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1191
+ // newest pick may update the hover state - an older one describes a pointer position the
1192
+ // user has already left, and one from an earlier connection describes a scene that no
1193
+ // longer exists.
1194
+ const generation = this._generation;
1195
+ const token = ++this._pickToken;
1196
+ const node = await this._pickNode(event);
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
+
910
1303
  /**
911
1304
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
912
1305
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -990,31 +1383,16 @@ class AppElement extends AsyncElement {
990
1383
  * engine scene nodes back to their owning elements by identity - never by name.
991
1384
  */
992
1385
  _entityElements = new Map();
993
- _picker = null;
994
- _hoveredEntity = null;
995
- // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
996
- _pickToken = 0;
997
- _pointerHandlers = {
998
- pointermove: null,
999
- pointerdown: null,
1000
- pointerup: null,
1001
- pointercancel: null
1002
- };
1003
- /**
1004
- * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
1005
- * may still conclude it. The promise is stored rather than its result, so a release can
1006
- * await a press pick that has not resolved yet. Entries are removed by the matching
1007
- * pointerup or pointercancel, and only ever stored while some element listens for click -
1008
- * which is also what keeps those two canvas listeners attached.
1009
- */
1010
- _downPicks = new Map();
1011
- /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
1012
- _clickListened = false;
1013
1386
  /**
1014
- * The previous click's target, time and count, for chaining successive clicks into the
1015
- * click count that `detail` carries. `null` until a click has fired.
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.
1016
1391
  */
1017
- _lastClick = null;
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
+ });
1018
1396
  _app = null;
1019
1397
  _loadProgress = 0;
1020
1398
  /**
@@ -1053,8 +1431,8 @@ class AppElement extends AsyncElement {
1053
1431
  // entities. Registered once here rather than on every boot - the sync no-ops while there
1054
1432
  // is no canvas, and a re-booted element must not stack a second set.
1055
1433
  SYNTHESIZED_EVENTS.forEach((type) => {
1056
- this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1057
- this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
1434
+ this.addEventListener(`${type}:connect`, () => this._pointer.syncListeners());
1435
+ this.addEventListener(`${type}:disconnect`, () => this._pointer.syncListeners());
1058
1436
  });
1059
1437
  }
1060
1438
  async connectedCallback() {
@@ -1228,7 +1606,7 @@ class AppElement extends AsyncElement {
1228
1606
  // from the canvas's client size
1229
1607
  app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
1230
1608
  app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
1231
- this._pickerCreate();
1609
+ this._pointer.connect(app, this._canvas);
1232
1610
  // Track the element's box rather than the window: containers resize without any window
1233
1611
  // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1234
1612
  // ResizeObserver - there, the resolution set above is the only sizing that happens.
@@ -1320,7 +1698,7 @@ class AppElement extends AsyncElement {
1320
1698
  // instead of completing against a torn-down element.
1321
1699
  this._bootGeneration++;
1322
1700
  this._optionsLocked = false;
1323
- this._pickerDestroy();
1701
+ this._pointer.disconnect();
1324
1702
  // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1325
1703
  // unregister them - clear() covers any entity the engine no longer reached.
1326
1704
  if (this._app) {
@@ -1356,49 +1734,7 @@ class AppElement extends AsyncElement {
1356
1734
  }
1357
1735
  this.app.updateCanvasSize();
1358
1736
  const { width, height } = this.app.graphicsDevice;
1359
- this._picker?.resize(width, height);
1360
- }
1361
- _pickerCreate() {
1362
- const { width, height } = this.app.graphicsDevice;
1363
- this._picker = new playcanvas.Picker(this.app, width, height);
1364
- // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
1365
- // each is wrapped to discard the promise - a listener must not return one, and nothing
1366
- // awaits the result.
1367
- const listener = (handler) => {
1368
- return (event) => {
1369
- handler.call(this, event);
1370
- };
1371
- };
1372
- this._pointerHandlers.pointermove = listener(this._onPointerMove);
1373
- this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1374
- this._pointerHandlers.pointerup = listener(this._onPointerUp);
1375
- this._pointerHandlers.pointercancel = (event) => {
1376
- this._downPicks.delete(event.pointerId);
1377
- };
1378
- // Attach canvas listeners for element listeners registered before this boot (e.g.
1379
- // handlers created from inline attributes when their elements were first upgraded, or
1380
- // listeners carried over from before a re-boot)
1381
- this._syncCanvasListeners();
1382
- }
1383
- _pickerDestroy() {
1384
- if (this._canvas) {
1385
- Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
1386
- if (handler) {
1387
- this._canvas.removeEventListener(type, handler);
1388
- }
1389
- });
1390
- }
1391
- this._picker = null;
1392
- this._hoveredEntity = null;
1393
- this._pointerHandlers = {
1394
- pointermove: null,
1395
- pointerdown: null,
1396
- pointerup: null,
1397
- pointercancel: null
1398
- };
1399
- this._downPicks.clear();
1400
- this._clickListened = false;
1401
- this._lastClick = null;
1737
+ this._pointer.resize(width, height);
1402
1738
  }
1403
1739
  /**
1404
1740
  * Registers the element that fronts an entity. Called by EntityElement when it creates its
@@ -1426,268 +1762,11 @@ class AppElement extends AsyncElement {
1426
1762
  * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1427
1763
  * through the engine API.
1428
1764
  *
1429
- * @param entity - The entity to look up.
1430
- * @returns The element fronting the entity, or `null`.
1431
- */
1432
- elementFromEntity(entity) {
1433
- return this._entityElements.get(entity) ?? null;
1434
- }
1435
- /**
1436
- * Resolves the element that owns hover for a picked node: the nearest node up the parent
1437
- * chain - starting with the node itself - whose element listens for any of the hover event
1438
- * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1439
- * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1440
- * to hover rather than swallowing a listening ancestor's enter/leave pair.
1441
- *
1442
- * @param node - The picked node, or `null`.
1443
- * @returns The hover-owning element, or `null`.
1444
- */
1445
- _hoverTarget(node) {
1446
- while (node !== null) {
1447
- const element = this._entityElements.get(node);
1448
- if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1449
- return element;
1450
- }
1451
- node = node.parent;
1452
- }
1453
- return null;
1454
- }
1455
- /**
1456
- * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1457
- * on an unlistened child still reaches a listening ancestor.
1458
- *
1459
- * @param node - The picked node, or `null`.
1460
- * @param type - The pointer event type a listener is required for.
1461
- * @returns The nearest listening element, or `null`.
1462
- */
1463
- _elementWithListener(node, type) {
1464
- while (node !== null) {
1465
- const element = this._entityElements.get(node);
1466
- if (element?._hasListeners(type)) {
1467
- return element;
1468
- }
1469
- node = node.parent;
1470
- }
1471
- return null;
1472
- }
1473
- /**
1474
- * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1475
- * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1476
- * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1477
- * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1478
- * coordinates correspond to no real geometry.
1479
- *
1480
- * @param event - The pointer event to convert.
1481
- * @param canvas - The canvas the event was dispatched on.
1482
- * @returns The buffer-space coordinates, and whether they were actually mapped.
1483
- */
1484
- _getPickerCoordinates(event, canvas) {
1485
- const canvasRect = canvas.getBoundingClientRect();
1486
- if (canvasRect.width === 0 || canvasRect.height === 0) {
1487
- return { x: event.clientX, y: event.clientY, mapped: false };
1488
- }
1489
- const scaleX = canvas.width / canvasRect.width;
1490
- const scaleY = canvas.height / canvasRect.height;
1491
- return {
1492
- x: (event.clientX - canvasRect.left) * scaleX,
1493
- y: (event.clientY - canvasRect.top) * scaleY,
1494
- mapped: true
1495
- };
1496
- }
1497
- /**
1498
- * Whether a camera's viewport contains the point. A camera renders into its normalized
1499
- * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1500
- * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1501
- * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1502
- * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1503
- * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1504
- * holds nothing there.
1505
- *
1506
- * @param camera - The camera to test.
1507
- * @param x - The x coordinate, in buffer space.
1508
- * @param y - The y coordinate, in buffer space.
1509
- * @param canvas - The canvas the coordinates are relative to.
1510
- * @returns Whether the camera's viewport contains the point.
1511
- */
1512
- _cameraContains(camera, x, y, canvas) {
1513
- const rect = camera.rect;
1514
- const left = rect.x * canvas.width;
1515
- const bottom = (1 - rect.y) * canvas.height;
1516
- const top = bottom - rect.w * canvas.height;
1517
- return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1518
- }
1519
- /**
1520
- * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1521
- *
1522
- * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1523
- * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1524
- * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1525
- * contain the pointer. A camera that picks nothing ends the search if it clears the color
1526
- * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1527
- * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1528
- * The pick buffer is prepared per camera, so each camera picks from its own layers.
1529
- *
1530
- * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1531
- * supported on WebGPU, where it returns an empty selection rather than failing - which
1532
- * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1533
- * async variant works on both backends and does not block the main thread on a GPU read.
1534
- *
1535
- * @param event - The pointer event to pick under.
1536
- * @returns The graph node under the pointer, or `null` if nothing was hit.
1537
- */
1538
- async _pickNode(event) {
1539
- const app = this.app;
1540
- const picker = this._picker;
1541
- const canvas = this._canvas;
1542
- if (!app || !picker || !canvas)
1543
- return null;
1544
- const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1545
- // Walked from the end: the array is sorted by ascending priority, so the last camera
1546
- // renders last and sits on top. Read through .at() because a pick handler may remove
1547
- // cameras while an earlier iteration's read back is in flight.
1548
- const cameras = app.systems.camera?.cameras ?? [];
1549
- for (let i = cameras.length - 1; i >= 0; i--) {
1550
- const camera = cameras.at(i);
1551
- // A camera rendering to a texture is not on the canvas.
1552
- if (!camera || camera.renderTarget)
1553
- continue;
1554
- // Coordinates that could not be mapped cannot be tested for containment.
1555
- if (mapped && !this._cameraContains(camera, x, y, canvas))
1556
- continue;
1557
- picker.prepare(camera, app.scene);
1558
- const selection = await picker.getSelectionAsync(x, y);
1559
- // The element may have disconnected while the read back was in flight.
1560
- if (!this._picker || !this.app)
1561
- return null;
1562
- if (selection.length > 0) {
1563
- const item = selection[0];
1564
- return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1565
- }
1566
- // Nothing hit. A camera that clears the color buffer paints its background over
1567
- // everything beneath it, so the miss is final; one that does not is an overlay
1568
- // that the cameras beneath show through, so they get their turn.
1569
- if (camera.clearColorBuffer)
1570
- return null;
1571
- }
1572
- return null;
1573
- }
1574
- async _onPointerMove(event) {
1575
- if (!this._picker || !this.app)
1576
- return;
1577
- // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1578
- // newest pick may update the hover state - an older one describes a pointer position the
1579
- // user has already left.
1580
- const token = ++this._pickToken;
1581
- const node = await this._pickNode(event);
1582
- if (token !== this._pickToken || !this._picker)
1583
- return;
1584
- // The hovered element is the nearest one up the node's parent chain with a hover
1585
- // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1586
- // type below: having any hover listener selects the target, each event needs its own.
1587
- const newHoverEntity = this._hoverTarget(node);
1588
- // Handle enter/leave events
1589
- if (this._hoveredEntity !== newHoverEntity) {
1590
- if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1591
- this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1592
- }
1593
- if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1594
- newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1595
- }
1596
- }
1597
- // Update hover state
1598
- this._hoveredEntity = newHoverEntity;
1599
- // Handle pointermove event
1600
- if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1601
- newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1602
- }
1603
- }
1604
- async _onPointerDown(event) {
1605
- if (!this._picker || !this.app)
1606
- return;
1607
- const pick = this._pickNode(event);
1608
- // A click concludes on the matching pointerup, which needs to know what the press
1609
- // picked. Primary button only - the only button a click can conclude from - and only
1610
- // while click is listened for, since it is the click mapping that keeps the pointerup
1611
- // and pointercancel listeners attached to clean the entry up again.
1612
- if (this._clickListened && event.button === 0) {
1613
- this._downPicks.set(event.pointerId, pick);
1614
- }
1615
- const node = await pick;
1616
- if (!this._picker)
1617
- return; // the element disconnected while the pick was in flight
1618
- const entityElement = this._elementWithListener(node, 'pointerdown');
1619
- if (entityElement) {
1620
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1621
- }
1622
- }
1623
- async _onPointerUp(event) {
1624
- if (!this._picker || !this.app)
1625
- return;
1626
- // The press pick this release may conclude as a click. Claimed synchronously, so the
1627
- // entry is gone before any other event for this pointer can be handled.
1628
- const downPick = this._downPicks.get(event.pointerId);
1629
- this._downPicks.delete(event.pointerId);
1630
- const node = await this._pickNode(event);
1631
- if (!this._picker)
1632
- return; // the element disconnected while the pick was in flight
1633
- const entityElement = this._elementWithListener(node, 'pointerup');
1634
- if (entityElement) {
1635
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1636
- }
1637
- // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1638
- // what the press and the release picked, for the primary button only. The press pick
1639
- // may still be in flight - a quick tap resolves in pick order, not event order.
1640
- if (!downPick || event.button !== 0)
1641
- return;
1642
- const downNode = await downPick;
1643
- if (!this._picker)
1644
- return;
1645
- const clickElement = this._elementWithListener(commonAncestor(downNode, node), 'click');
1646
- if (clickElement) {
1647
- const click = new PointerEvent('click', event);
1648
- // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1649
- // at 0 - but click is exempt: its detail is the click count, chained here as the
1650
- // platform chains it (same target, within the double-click window). Overridden
1651
- // with defineProperty because an event instance used as an init dict cannot have
1652
- // single fields replaced.
1653
- const time = performance.now();
1654
- const last = this._lastClick;
1655
- const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1656
- this._lastClick = { element: clickElement, time, count };
1657
- Object.defineProperty(click, 'detail', { value: count });
1658
- clickElement.dispatchEvent(click);
1659
- }
1660
- }
1661
- /**
1662
- * Attaches exactly the canvas listeners the tree's current element listeners need, and
1663
- * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1664
- * this element: several synthesized types can need the same canvas listener (enter, leave
1665
- * and move all ride the move pick; click rides the down/up pair), so one type's removal
1666
- * must not detach a listener another type still uses. Re-attaching an attached listener is
1667
- * a no-op by EventTarget semantics, so no attach state is kept.
1765
+ * @param entity - The entity to look up.
1766
+ * @returns The element fronting the entity, or `null`.
1668
1767
  */
1669
- _syncCanvasListeners() {
1670
- const canvas = this._canvas;
1671
- if (!canvas)
1672
- return; // not booted yet: _pickerCreate syncs once the handlers exist
1673
- const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1674
- const needed = new Set();
1675
- for (const type of SYNTHESIZED_EVENTS) {
1676
- if (elements.some((element) => element._hasListeners(type))) {
1677
- canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1678
- }
1679
- }
1680
- this._clickListened = elements.some((element) => element._hasListeners('click'));
1681
- Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1682
- if (!handler)
1683
- return;
1684
- if (needed.has(canvasType)) {
1685
- canvas.addEventListener(canvasType, handler);
1686
- }
1687
- else {
1688
- canvas.removeEventListener(canvasType, handler);
1689
- }
1690
- });
1768
+ elementFromEntity(entity) {
1769
+ return this._entityElements.get(entity) ?? null;
1691
1770
  }
1692
1771
  /**
1693
1772
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -3089,6 +3168,108 @@ const useAsset = (id) => {
3089
3168
  return asset;
3090
3169
  };
3091
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
+ });
3268
+ }
3269
+ return asset;
3270
+ }
3271
+ }
3272
+
3092
3273
  /**
3093
3274
  * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
3094
3275
  * name is shared by several nodes in the model, the attached component types, and the material
@@ -3189,18 +3370,18 @@ class ModelElement extends EntityOwnerElement {
3189
3370
  _contentEntity = null;
3190
3371
  /**
3191
3372
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
3192
- * by a load when it starts. A load that resumes from an await or a load callback abandons
3193
- * itself if the value has moved on, so a superseded load can neither instantiate a second
3194
- * 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.
3195
3377
  */
3196
3378
  _loadGeneration = 0;
3197
3379
  /**
3198
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
3199
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
3200
- * 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.
3201
3383
  */
3202
- _loadHandle = null;
3203
- _errorHandle = null;
3384
+ _binding = new AssetBinding();
3204
3385
  /**
3205
3386
  * The root entity of the instantiated model content, parented beneath the host entity.
3206
3387
  * `null` until the container asset has loaded and been instantiated, after a failed load,
@@ -3291,7 +3472,7 @@ class ModelElement extends EntityOwnerElement {
3291
3472
  // resets the element. The generation guard comes first so a load suspended on an await
3292
3473
  // cannot resume against the torn-down element.
3293
3474
  this._loadGeneration++;
3294
- this._detachLoadHandlers();
3475
+ this._binding.cancel();
3295
3476
  this._entity?.destroy();
3296
3477
  }
3297
3478
  /**
@@ -3310,16 +3491,10 @@ class ModelElement extends EntityOwnerElement {
3310
3491
  */
3311
3492
  _onEntityDestroy(entity) {
3312
3493
  this._loadGeneration++;
3313
- this._detachLoadHandlers();
3494
+ this._binding.cancel();
3314
3495
  this._contentEntity = null;
3315
3496
  super._onEntityDestroy(entity);
3316
3497
  }
3317
- _detachLoadHandlers() {
3318
- this._loadHandle?.off();
3319
- this._loadHandle = null;
3320
- this._errorHandle?.off();
3321
- this._errorHandle = null;
3322
- }
3323
3498
  /**
3324
3499
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
3325
3500
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -3346,7 +3521,7 @@ class ModelElement extends EntityOwnerElement {
3346
3521
  this._destroyContent();
3347
3522
  // Supersede any load already in flight - only the newest load may instantiate
3348
3523
  const generation = ++this._loadGeneration;
3349
- this._detachLoadHandlers();
3524
+ this._binding.cancel();
3350
3525
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
3351
3526
  // content. A no-op on first connection, where readiness is still pending.
3352
3527
  this._resetReady();
@@ -3372,39 +3547,23 @@ class ModelElement extends EntityOwnerElement {
3372
3547
  this._onReady();
3373
3548
  return;
3374
3549
  }
3375
- const asset = useAsset(this._asset);
3376
- if (!asset) {
3377
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3378
- // silently pending.
3379
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3380
- return;
3381
- }
3382
- if (asset.loaded) {
3383
- this._instantiate(asset.resource);
3384
- }
3385
- else {
3386
- // The generation is re-checked even though a superseded handler is detached: the
3387
- // detach relies on how the engine's event emitter treats removal, while the check
3388
- // holds on its own. Whichever of load/error fires first detaches the other.
3389
- this._loadHandle = asset.once('load', () => {
3390
- this._detachLoadHandlers();
3391
- if (generation !== this._loadGeneration) {
3392
- return;
3393
- }
3394
- this._instantiate(asset.resource);
3395
- });
3396
- this._errorHandle = asset.once('error', (err) => {
3397
- this._detachLoadHandlers();
3398
- if (generation !== this._loadGeneration) {
3399
- return;
3400
- }
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) => {
3401
3555
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
3402
3556
  // readiness means the load settled, not that it succeeded.
3403
3557
  this.dispatchEvent(new ErrorEvent('error', {
3404
3558
  message: err instanceof Error ? err.message : String(err)
3405
3559
  }));
3406
3560
  this._onReady();
3407
- });
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`);
3408
3567
  }
3409
3568
  }
3410
3569
  /**
@@ -3539,6 +3698,8 @@ class ComponentElement extends AsyncElement {
3539
3698
  console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
3540
3699
  return;
3541
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
3542
3703
  this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
3543
3704
  }
3544
3705
  async _addComponent() {
@@ -3801,6 +3962,9 @@ class AnimComponentElement extends ComponentElement {
3801
3962
  */
3802
3963
  _applyRootBone() {
3803
3964
  const component = this.component;
3965
+ if (!component) {
3966
+ return;
3967
+ }
3804
3968
  // A non-null root this element did not assign came through the engine API. A fresh
3805
3969
  // component starts at null, which is always reclaimable.
3806
3970
  if (component.rootBone !== null && component.rootBone !== this._managedRootBone) {
@@ -3873,7 +4037,7 @@ class AnimComponentElement extends ComponentElement {
3873
4037
  * declared `clip` selection can apply before any asset has loaded.
3874
4038
  */
3875
4039
  _assignClip(clip) {
3876
- 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);
3877
4041
  }
3878
4042
  /**
3879
4043
  * Validates a clip child and, when valid, assigns its state and starts its track resolution.
@@ -4181,8 +4345,9 @@ class AnimComponentElement extends ComponentElement {
4181
4345
  component.playing = true;
4182
4346
  }
4183
4347
  /**
4184
- * Gets the underlying PlayCanvas anim component.
4185
- * @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`.
4186
4351
  */
4187
4352
  get component() {
4188
4353
  return super.component;
@@ -4331,6 +4496,11 @@ class AnimClipElement extends AsyncElement {
4331
4496
  */
4332
4497
  _animElement = null;
4333
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();
4334
4504
  /**
4335
4505
  * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
4336
4506
  * a resume from an await abandons itself if the value has moved on, so a stale callback can
@@ -4338,19 +4508,13 @@ class AnimClipElement extends AsyncElement {
4338
4508
  * own callback.
4339
4509
  */
4340
4510
  _connectionGeneration = 0;
4341
- _errorHandle = null;
4342
4511
  /**
4343
4512
  * Incremented on every track resolution and on disconnect, and captured by a resolution when
4344
- * it starts. A resolution that resumes from an await or an asset callback abandons itself if
4345
- * 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.
4346
4516
  */
4347
4517
  _loadGeneration = 0;
4348
- /**
4349
- * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
4350
- * Held so that whatever supersedes the resolution can detach the handlers from the asset,
4351
- * rather than leave them registered until the asset settles (or forever, if it never does).
4352
- */
4353
- _loadHandle = null;
4354
4518
  _loop = true;
4355
4519
  _name = '';
4356
4520
  _speed = 1;
@@ -4390,7 +4554,7 @@ class AnimClipElement extends AsyncElement {
4390
4554
  // Invalidate any connectedCallback or track resolution still suspended on an await
4391
4555
  this._connectionGeneration++;
4392
4556
  this._loadGeneration++;
4393
- this._detachLoadHandlers();
4557
+ this._binding.cancel();
4394
4558
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
4395
4559
  // by now. The component itself is null if the whole <pc-app> is being torn down —
4396
4560
  // parents disconnect first and have already removed the component.
@@ -4408,12 +4572,6 @@ class AnimClipElement extends AsyncElement {
4408
4572
  }
4409
4573
  return animElement;
4410
4574
  }
4411
- _detachLoadHandlers() {
4412
- this._loadHandle?.off();
4413
- this._loadHandle = null;
4414
- this._errorHandle?.off();
4415
- this._errorHandle = null;
4416
- }
4417
4575
  /**
4418
4576
  * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
4419
4577
  *
@@ -4439,34 +4597,19 @@ class AnimClipElement extends AsyncElement {
4439
4597
  async _resolveTrack(animElement) {
4440
4598
  this._animElement = animElement;
4441
4599
  const generation = ++this._loadGeneration;
4442
- this._detachLoadHandlers();
4600
+ this._binding.cancel();
4443
4601
  if (this._asset) {
4444
- 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
+ });
4445
4610
  if (!asset) {
4446
4611
  this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
4447
- return;
4448
- }
4449
- if (asset.loaded) {
4450
- this._extractTrack(asset, `asset '${this._asset}'`);
4451
- return;
4452
4612
  }
4453
- // Whichever of load/error fires first detaches the other. The generation is
4454
- // re-checked even though a superseded handler is detached: the detach relies on how
4455
- // the engine's event emitter treats removal, while the check holds on its own.
4456
- this._loadHandle = asset.once('load', () => {
4457
- this._detachLoadHandlers();
4458
- if (generation !== this._loadGeneration) {
4459
- return;
4460
- }
4461
- this._extractTrack(asset, `asset '${this._asset}'`);
4462
- });
4463
- this._errorHandle = asset.once('error', () => {
4464
- this._detachLoadHandlers();
4465
- if (generation !== this._loadGeneration) {
4466
- return;
4467
- }
4468
- this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4469
- });
4470
4613
  return;
4471
4614
  }
4472
4615
  const model = animElement.parentElement;
@@ -4658,8 +4801,9 @@ class AudioListenerComponentElement extends ComponentElement {
4658
4801
  super('audiolistener');
4659
4802
  }
4660
4803
  /**
4661
- * Gets the underlying PlayCanvas audio listener component.
4662
- * @returns The audio listener component.
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`.
4663
4807
  */
4664
4808
  get component() {
4665
4809
  return super.component;
@@ -4667,6 +4811,227 @@ class AudioListenerComponentElement extends ComponentElement {
4667
4811
  }
4668
4812
  customElements.define('pc-audio-listener', AudioListenerComponentElement);
4669
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);
4916
+ }
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);
4929
+ }
4930
+ const app = from.parentElement?.closest('pc-app');
4931
+ if (app) {
4932
+ const element = queryScope(app, nameSelector);
4933
+ if (element) {
4934
+ return element;
4935
+ }
4936
+ }
4937
+ }
4938
+ return query(nameSelector);
4939
+ };
4940
+ /**
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.
4946
+ *
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.
4963
+ *
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.
4984
+ *
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
4989
+ */
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;
5016
+ }
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}`);
5031
+ }
5032
+ return entity;
5033
+ };
5034
+
4670
5035
  const transitionModes = new Map([
4671
5036
  ['tint', playcanvas.BUTTON_TRANSITION_MODE_TINT],
4672
5037
  ['sprite', playcanvas.BUTTON_TRANSITION_MODE_SPRITE_CHANGE]
@@ -4720,7 +5085,9 @@ class ButtonComponentElement extends ComponentElement {
4720
5085
  };
4721
5086
  // The image entity defaults to the button's own entity (which carries the image element)
4722
5087
  // when no explicit reference is provided.
4723
- const imageEntity = this._image ? getEntity(this._image) : this.closestEntity?.entity;
5088
+ const imageEntity = this._image
5089
+ ? resolveEntity(this._image, this, 'image', 'reference ignored')
5090
+ : this.closestEntity?.entity;
4724
5091
  if (imageEntity) {
4725
5092
  data.imageEntity = imageEntity;
4726
5093
  }
@@ -4739,8 +5106,9 @@ class ButtonComponentElement extends ComponentElement {
4739
5106
  return data;
4740
5107
  }
4741
5108
  /**
4742
- * Gets the underlying PlayCanvas button component.
4743
- * @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`.
4744
5112
  */
4745
5113
  get component() {
4746
5114
  return super.component;
@@ -4763,21 +5131,27 @@ class ButtonComponentElement extends ComponentElement {
4763
5131
  return this._active;
4764
5132
  }
4765
5133
  /**
4766
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` whose image
4767
- * element is used for visual transitions. Defaults to the button's own entity — inside a
4768
- * `<pc-model>`, that is the model's host entity, so supply an explicit reference to target a
4769
- * UI entity instead.
5134
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5135
+ * selector) to the entity whose image element is used for visual transitions. An exact name
5136
+ * resolves against the nearest enclosing entity first, then outward, then the document.
5137
+ * Defaults to the button's own entity — inside a `<pc-model>`, that is the model's host
5138
+ * entity, so supply an explicit reference to target a UI entity instead. A non-empty
5139
+ * reference that does not resolve warns and is ignored.
4770
5140
  * @param value - The image entity reference.
4771
5141
  */
4772
5142
  set image(value) {
4773
5143
  this._image = value;
4774
- const entity = getEntity(value);
4775
- if (this.component && entity) {
4776
- this.component.imageEntity = entity;
5144
+ if (this.component) {
5145
+ const entity = resolveEntity(value, this, 'image', 'reference ignored');
5146
+ if (entity) {
5147
+ this.component.imageEntity = entity;
5148
+ }
4777
5149
  }
4778
5150
  }
4779
5151
  /**
4780
- * Gets the reference to the `<pc-entity>` whose image element is used for visual transitions.
5152
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5153
+ * selector) to the entity whose image element is used for visual transitions, or empty for
5154
+ * the button's own entity.
4781
5155
  * @returns The image entity reference.
4782
5156
  */
4783
5157
  get image() {
@@ -5192,8 +5566,9 @@ class CameraComponentElement extends ComponentElement {
5192
5566
  }
5193
5567
  }
5194
5568
  /**
5195
- * Gets the underlying PlayCanvas camera component.
5196
- * @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`.
5197
5572
  */
5198
5573
  get component() {
5199
5574
  return super.component;
@@ -5682,8 +6057,9 @@ class CollisionComponentElement extends ComponentElement {
5682
6057
  component.renderAsset = asset;
5683
6058
  }
5684
6059
  /**
5685
- * Gets the underlying PlayCanvas collision component.
5686
- * @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`.
5687
6063
  */
5688
6064
  get component() {
5689
6065
  return super.component;
@@ -5921,8 +6297,9 @@ class ElementComponentElement extends ComponentElement {
5921
6297
  return data;
5922
6298
  }
5923
6299
  /**
5924
- * Gets the underlying PlayCanvas element component.
5925
- * @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`.
5926
6303
  */
5927
6304
  get component() {
5928
6305
  return super.component;
@@ -6505,7 +6882,12 @@ customElements.define('pc-element', ElementComponentElement);
6505
6882
  * primary axis: a hinge rotates about it, a slider translates along it and a ball joint twists
6506
6883
  * about it. The constrained bodies are referenced by `entity-a` and `entity-b`, both of which need
6507
6884
  * a rigid body component; leaving `entity-b` empty constrains `entity-a` to a fixed point in world
6508
- * space. The underlying engine component is in alpha, so its API may change.
6885
+ * space. A reference can name any entity-fronting element `<pc-entity>`, `<pc-model>` or
6886
+ * `<pc-node>`, so a ragdoll can join a model's own skeleton nodes by name — and a name resolves
6887
+ * against the nearest enclosing entity first, then outward through the entity hierarchy, then the
6888
+ * document, while a `#` selector resolves document-wide. A `<template>` prefab with one
6889
+ * entity-fronting root can therefore wire its joints by name and stay self-contained when cloned.
6890
+ * The underlying engine component is in alpha, so its API may change.
6509
6891
  *
6510
6892
  * @elementSummary The `<pc-joint>` element constrains two rigid bodies to each other — a hinged
6511
6893
  * door, a swinging chain, a sliding drawer. Its entity's transform is the joint frame, and
@@ -6659,8 +7041,8 @@ class JointComponentElement extends ComponentElement {
6659
7041
  breakImpulse: this._breakImpulse,
6660
7042
  enableCollision: this._enableCollision,
6661
7043
  enableLimits: this._enableLimits,
6662
- entityA: getEntity(this._entityA),
6663
- entityB: getEntity(this._entityB),
7044
+ entityA: resolveEntity(this._entityA, this, 'entity-a', 'constraint not created'),
7045
+ entityB: resolveEntity(this._entityB, this, 'entity-b', 'constraint not created'),
6664
7046
  limits: this._limits,
6665
7047
  linearDamping: this._linearDamping,
6666
7048
  linearEquilibrium: this._linearEquilibrium,
@@ -6694,8 +7076,9 @@ class JointComponentElement extends ComponentElement {
6694
7076
  component.on('break', this._onBreak, this);
6695
7077
  }
6696
7078
  /**
6697
- * Gets the underlying PlayCanvas joint component.
6698
- * @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`.
6699
7082
  */
6700
7083
  get component() {
6701
7084
  return super.component;
@@ -6915,39 +7298,48 @@ class JointComponentElement extends ComponentElement {
6915
7298
  return this._enableLimits;
6916
7299
  }
6917
7300
  /**
6918
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6919
- * the first constrained body. The reference resolves when it is set, so an entity created
6920
- * later is picked up by setting the attribute again.
7301
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7302
+ * selector) to the element providing the first constrained body. An exact name resolves
7303
+ * against the nearest enclosing entity first, then outward, then the document. The reference
7304
+ * resolves when it is set, so an entity created later is picked up by setting the attribute
7305
+ * again. A non-empty reference that does not resolve warns, naming which of the two causes it
7306
+ * hit.
6921
7307
  * @param value - The first body's entity reference.
6922
7308
  */
6923
7309
  set entityA(value) {
6924
7310
  this._entityA = value;
6925
7311
  if (this.component) {
6926
- this.component.entityA = getEntity(value);
7312
+ this.component.entityA = resolveEntity(value, this, 'entity-a', 'constraint not created');
6927
7313
  }
6928
7314
  }
6929
7315
  /**
6930
- * Gets the reference to the `<pc-entity>` providing the first constrained body.
7316
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7317
+ * selector) to the element providing the first constrained body.
6931
7318
  * @returns The first body's entity reference.
6932
7319
  */
6933
7320
  get entityA() {
6934
7321
  return this._entityA;
6935
7322
  }
6936
7323
  /**
6937
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6938
- * the second constrained body, or empty to constrain the first body to a fixed point in world
6939
- * space. The reference resolves when it is set, so an entity created later is picked up by
6940
- * setting the attribute again.
7324
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7325
+ * selector) to the element providing the second constrained body, or empty to constrain the
7326
+ * first body to a fixed point in world space. An exact name resolves against the nearest
7327
+ * enclosing entity first, then outward, then the document. The reference resolves when it is
7328
+ * set, so an entity created later is picked up by setting the attribute again. A non-empty
7329
+ * reference that does not resolve warns; an empty one is the documented world-space case and
7330
+ * stays silent.
6941
7331
  * @param value - The second body's entity reference.
6942
7332
  */
6943
7333
  set entityB(value) {
6944
7334
  this._entityB = value;
6945
7335
  if (this.component) {
6946
- this.component.entityB = getEntity(value);
7336
+ this.component.entityB = resolveEntity(value, this, 'entity-b', 'constraint not created');
6947
7337
  }
6948
7338
  }
6949
7339
  /**
6950
- * Gets the reference to the `<pc-entity>` providing the second constrained body.
7340
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7341
+ * selector) to the element providing the second constrained body, or empty for the
7342
+ * world-space case.
6951
7343
  * @returns The second body's entity reference.
6952
7344
  */
6953
7345
  get entityB() {
@@ -7412,8 +7804,9 @@ class LayoutChildComponentElement extends ComponentElement {
7412
7804
  };
7413
7805
  }
7414
7806
  /**
7415
- * Gets the underlying PlayCanvas layout child component.
7416
- * @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`.
7417
7810
  */
7418
7811
  get component() {
7419
7812
  return super.component;
@@ -7632,8 +8025,9 @@ class LayoutGroupComponentElement extends ComponentElement {
7632
8025
  };
7633
8026
  }
7634
8027
  /**
7635
- * Gets the underlying PlayCanvas layout group component.
7636
- * @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`.
7637
8031
  */
7638
8032
  get component() {
7639
8033
  return super.component;
@@ -7922,8 +8316,9 @@ class LightComponentElement extends ComponentElement {
7922
8316
  };
7923
8317
  }
7924
8318
  /**
7925
- * Gets the underlying PlayCanvas light component.
7926
- * @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`.
7927
8322
  */
7928
8323
  get component() {
7929
8324
  return super.component;
@@ -8439,36 +8834,68 @@ customElements.define('pc-light', LightComponentElement);
8439
8834
  */
8440
8835
  class ParticleSystemComponentElement extends ComponentElement {
8441
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();
8442
8844
  /** @ignore */
8443
8845
  constructor() {
8444
8846
  super('particlesystem');
8445
8847
  }
8446
8848
  getInitialComponentData() {
8447
8849
  const asset = useAsset(this._asset);
8448
- // 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
8449
8852
  if (!asset || !asset.resource) {
8450
8853
  return {};
8451
8854
  }
8452
- if (asset.resource.colorMapAsset) {
8453
- const id = asset.resource.colorMapAsset;
8454
- const colorMapAsset = useAsset(id)?.id;
8455
- if (colorMapAsset) {
8456
- asset.resource.colorMapAsset = colorMapAsset;
8457
- }
8458
- }
8855
+ this._resolveColorMap(asset.resource);
8459
8856
  return asset.resource;
8460
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
+ }
8461
8872
  /**
8462
- * Gets the underlying PlayCanvas particle system component.
8463
- * @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`.
8464
8876
  */
8465
8877
  get component() {
8466
8878
  return super.component;
8467
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
+ }
8468
8894
  applyConfig(resource) {
8469
8895
  if (!this.component) {
8470
8896
  return;
8471
8897
  }
8898
+ this._resolveColorMap(resource);
8472
8899
  // Set all the config properties on the component
8473
8900
  for (const key in resource) {
8474
8901
  if (Object.hasOwn(resource, key)) {
@@ -8476,20 +8903,10 @@ class ParticleSystemComponentElement extends ComponentElement {
8476
8903
  }
8477
8904
  }
8478
8905
  }
8479
- async _loadAsset() {
8480
- await this.closestApp?.ready();
8481
- const asset = useAsset(this._asset);
8482
- if (!asset) {
8483
- return;
8484
- }
8485
- if (asset.loaded) {
8486
- this.applyConfig(asset.resource);
8487
- }
8488
- else {
8489
- asset.once('load', () => {
8490
- this.applyConfig(asset.resource);
8491
- });
8492
- }
8906
+ _bindConfig() {
8907
+ this._binding.bind(this._asset, {
8908
+ load: (asset) => this.applyConfig(asset.resource)
8909
+ });
8493
8910
  }
8494
8911
  /**
8495
8912
  * Sets the id of the `pc-asset` to use for the model.
@@ -8498,7 +8915,7 @@ class ParticleSystemComponentElement extends ComponentElement {
8498
8915
  set asset(value) {
8499
8916
  this._asset = value;
8500
8917
  if (this.isConnected) {
8501
- this._loadAsset();
8918
+ this._bindConfig();
8502
8919
  }
8503
8920
  }
8504
8921
  /**
@@ -8715,11 +9132,12 @@ class MaterialElement extends HTMLElement {
8715
9132
  _useSkybox = true;
8716
9133
  _useTonemap = true;
8717
9134
  /**
8718
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
8719
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
8720
- * 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.
8721
9139
  */
8722
- _mapHandles = new Map();
9140
+ _mapBindings = new Map();
8723
9141
  _updateScheduled = false;
8724
9142
  _glossConflictWarned = false;
8725
9143
  /**
@@ -8846,10 +9264,9 @@ class MaterialElement extends HTMLElement {
8846
9264
  material.update();
8847
9265
  }
8848
9266
  disconnectedCallback() {
8849
- for (const handle of this._mapHandles.values()) {
8850
- handle.off();
9267
+ for (const binding of this._mapBindings.values()) {
9268
+ binding.cancel();
8851
9269
  }
8852
- this._mapHandles.clear();
8853
9270
  if (this.material) {
8854
9271
  this.material.destroy();
8855
9272
  this.material = null;
@@ -8895,16 +9312,23 @@ class MaterialElement extends HTMLElement {
8895
9312
  'the roughness-* attributes invert gloss, so the two families contradict each other. Use one or the other.');
8896
9313
  }
8897
9314
  /**
8898
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
8899
- * 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.
8900
9318
  *
8901
9319
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
8902
9320
  * @param slot - The material property to write.
8903
9321
  */
8904
9322
  _setMap(id, slot) {
8905
- // Drop any load still pending for this slot - its texture is no longer the one we want
8906
- this._mapHandles.get(slot)?.off();
8907
- 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();
8908
9332
  if (!this.material)
8909
9333
  return;
8910
9334
  if (!id) {
@@ -8912,17 +9336,9 @@ class MaterialElement extends HTMLElement {
8912
9336
  this._scheduleUpdate();
8913
9337
  return;
8914
9338
  }
8915
- const asset = useAsset(id);
8916
- if (!asset)
8917
- return;
8918
- if (asset.loaded) {
8919
- this._applyMap(slot, asset.resource);
8920
- return;
8921
- }
8922
- this._mapHandles.set(slot, asset.once('load', () => {
8923
- this._mapHandles.delete(slot);
8924
- this._applyMap(slot, asset.resource);
8925
- }));
9339
+ binding.bind(id, {
9340
+ load: (asset) => this._applyMap(slot, asset.resource)
9341
+ });
8926
9342
  }
8927
9343
  /**
8928
9344
  * @param slot - The material property to write.
@@ -10818,8 +11234,9 @@ class RenderComponentElement extends ComponentElement {
10818
11234
  };
10819
11235
  }
10820
11236
  /**
10821
- * Gets the underlying PlayCanvas render component.
10822
- * @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`.
10823
11240
  */
10824
11241
  get component() {
10825
11242
  return super.component;
@@ -10990,8 +11407,9 @@ class RigidBodyComponentElement extends ComponentElement {
10990
11407
  };
10991
11408
  }
10992
11409
  /**
10993
- * Gets the underlying PlayCanvas rigidbody component.
10994
- * @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`.
10995
11413
  */
10996
11414
  get component() {
10997
11415
  return super.component;
@@ -11170,8 +11588,9 @@ class ScreenComponentElement extends ComponentElement {
11170
11588
  };
11171
11589
  }
11172
11590
  /**
11173
- * Gets the underlying PlayCanvas screen component.
11174
- * @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`.
11175
11594
  */
11176
11595
  get component() {
11177
11596
  return super.component;
@@ -11321,15 +11740,16 @@ class ScrollbarComponentElement extends ComponentElement {
11321
11740
  value: this._value,
11322
11741
  handleSize: this._handleSize
11323
11742
  };
11324
- const handle = getEntity(this._handle);
11743
+ const handle = resolveEntity(this._handle, this, 'handle', 'reference ignored');
11325
11744
  if (handle) {
11326
11745
  data.handleEntity = handle;
11327
11746
  }
11328
11747
  return data;
11329
11748
  }
11330
11749
  /**
11331
- * Gets the underlying PlayCanvas scrollbar component.
11332
- * @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`.
11333
11753
  */
11334
11754
  get component() {
11335
11755
  return super.component;
@@ -11387,19 +11807,24 @@ class ScrollbarComponentElement extends ComponentElement {
11387
11807
  return this._handleSize;
11388
11808
  }
11389
11809
  /**
11390
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11391
- * scrollbar handle.
11810
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11811
+ * selector) to the entity used as the scrollbar handle. An exact name resolves against the
11812
+ * nearest enclosing entity first, then outward, then the document. A non-empty reference that
11813
+ * does not resolve warns and is ignored.
11392
11814
  * @param value - The handle entity reference.
11393
11815
  */
11394
11816
  set handle(value) {
11395
11817
  this._handle = value;
11396
- const entity = getEntity(value);
11397
- if (this.component && entity) {
11398
- this.component.handleEntity = entity;
11818
+ if (this.component) {
11819
+ const entity = resolveEntity(value, this, 'handle', 'reference ignored');
11820
+ if (entity) {
11821
+ this.component.handleEntity = entity;
11822
+ }
11399
11823
  }
11400
11824
  }
11401
11825
  /**
11402
- * Gets the reference to the `<pc-entity>` used as the scrollbar handle.
11826
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11827
+ * selector) to the entity used as the scrollbar handle.
11403
11828
  * @returns The handle entity reference.
11404
11829
  */
11405
11830
  get handle() {
@@ -11481,27 +11906,28 @@ class ScrollViewComponentElement extends ComponentElement {
11481
11906
  horizontalScrollbarVisibility: visibilities.get(this._horizontalScrollbarVisibility),
11482
11907
  verticalScrollbarVisibility: visibilities.get(this._verticalScrollbarVisibility)
11483
11908
  };
11484
- const viewport = getEntity(this._viewport);
11909
+ const viewport = resolveEntity(this._viewport, this, 'viewport', 'reference ignored');
11485
11910
  if (viewport) {
11486
11911
  data.viewportEntity = viewport;
11487
11912
  }
11488
- const content = getEntity(this._content);
11913
+ const content = resolveEntity(this._content, this, 'content', 'reference ignored');
11489
11914
  if (content) {
11490
11915
  data.contentEntity = content;
11491
11916
  }
11492
- const horizontalScrollbar = getEntity(this._horizontalScrollbar);
11917
+ const horizontalScrollbar = resolveEntity(this._horizontalScrollbar, this, 'horizontal-scrollbar', 'reference ignored');
11493
11918
  if (horizontalScrollbar) {
11494
11919
  data.horizontalScrollbarEntity = horizontalScrollbar;
11495
11920
  }
11496
- const verticalScrollbar = getEntity(this._verticalScrollbar);
11921
+ const verticalScrollbar = resolveEntity(this._verticalScrollbar, this, 'vertical-scrollbar', 'reference ignored');
11497
11922
  if (verticalScrollbar) {
11498
11923
  data.verticalScrollbarEntity = verticalScrollbar;
11499
11924
  }
11500
11925
  return data;
11501
11926
  }
11502
11927
  /**
11503
- * Gets the underlying PlayCanvas scroll view component.
11504
- * @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`.
11505
11931
  */
11506
11932
  get component() {
11507
11933
  return super.component;
@@ -11669,76 +12095,96 @@ class ScrollViewComponentElement extends ComponentElement {
11669
12095
  return this._verticalScrollbarVisibility;
11670
12096
  }
11671
12097
  /**
11672
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11673
- * viewport, which clips the content to the scroll view's bounds.
12098
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12099
+ * selector) to the entity used as the viewport, which clips the content to the scroll view's
12100
+ * bounds. An exact name resolves against the nearest enclosing entity first, then outward,
12101
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11674
12102
  * @param value - The viewport entity reference.
11675
12103
  */
11676
12104
  set viewport(value) {
11677
12105
  this._viewport = value;
11678
- const entity = getEntity(value);
11679
- if (this.component && entity) {
11680
- this.component.viewportEntity = entity;
12106
+ if (this.component) {
12107
+ const entity = resolveEntity(value, this, 'viewport', 'reference ignored');
12108
+ if (entity) {
12109
+ this.component.viewportEntity = entity;
12110
+ }
11681
12111
  }
11682
12112
  }
11683
12113
  /**
11684
- * Gets the reference to the `<pc-entity>` used as the viewport.
12114
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12115
+ * selector) to the entity used as the viewport.
11685
12116
  * @returns The viewport entity reference.
11686
12117
  */
11687
12118
  get viewport() {
11688
12119
  return this._viewport;
11689
12120
  }
11690
12121
  /**
11691
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11692
- * content, which is moved as the scroll view is scrolled.
12122
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12123
+ * selector) to the entity used as the content, which is moved as the scroll view is
12124
+ * scrolled. An exact name resolves against the nearest enclosing entity first, then outward,
12125
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11693
12126
  * @param value - The content entity reference.
11694
12127
  */
11695
12128
  set content(value) {
11696
12129
  this._content = value;
11697
- const entity = getEntity(value);
11698
- if (this.component && entity) {
11699
- this.component.contentEntity = entity;
12130
+ if (this.component) {
12131
+ const entity = resolveEntity(value, this, 'content', 'reference ignored');
12132
+ if (entity) {
12133
+ this.component.contentEntity = entity;
12134
+ }
11700
12135
  }
11701
12136
  }
11702
12137
  /**
11703
- * Gets the reference to the `<pc-entity>` used as the content.
12138
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12139
+ * selector) to the entity used as the content.
11704
12140
  * @returns The content entity reference.
11705
12141
  */
11706
12142
  get content() {
11707
12143
  return this._content;
11708
12144
  }
11709
12145
  /**
11710
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11711
- * the horizontal `<pc-scrollbar>`.
12146
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12147
+ * selector) to the entity containing the horizontal `<pc-scrollbar>`. An exact name resolves
12148
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12149
+ * reference that does not resolve warns and is ignored.
11712
12150
  * @param value - The horizontal scrollbar entity reference.
11713
12151
  */
11714
12152
  set horizontalScrollbar(value) {
11715
12153
  this._horizontalScrollbar = value;
11716
- const entity = getEntity(value);
11717
- if (this.component && entity) {
11718
- this.component.horizontalScrollbarEntity = entity;
12154
+ if (this.component) {
12155
+ const entity = resolveEntity(value, this, 'horizontal-scrollbar', 'reference ignored');
12156
+ if (entity) {
12157
+ this.component.horizontalScrollbarEntity = entity;
12158
+ }
11719
12159
  }
11720
12160
  }
11721
12161
  /**
11722
- * Gets the reference to the `<pc-entity>` containing the horizontal scrollbar.
12162
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12163
+ * selector) to the entity containing the horizontal scrollbar.
11723
12164
  * @returns The horizontal scrollbar entity reference.
11724
12165
  */
11725
12166
  get horizontalScrollbar() {
11726
12167
  return this._horizontalScrollbar;
11727
12168
  }
11728
12169
  /**
11729
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11730
- * the vertical `<pc-scrollbar>`.
12170
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12171
+ * selector) to the entity containing the vertical `<pc-scrollbar>`. An exact name resolves
12172
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12173
+ * reference that does not resolve warns and is ignored.
11731
12174
  * @param value - The vertical scrollbar entity reference.
11732
12175
  */
11733
12176
  set verticalScrollbar(value) {
11734
12177
  this._verticalScrollbar = value;
11735
- const entity = getEntity(value);
11736
- if (this.component && entity) {
11737
- this.component.verticalScrollbarEntity = entity;
12178
+ if (this.component) {
12179
+ const entity = resolveEntity(value, this, 'vertical-scrollbar', 'reference ignored');
12180
+ if (entity) {
12181
+ this.component.verticalScrollbarEntity = entity;
12182
+ }
11738
12183
  }
11739
12184
  }
11740
12185
  /**
11741
- * Gets the reference to the `<pc-entity>` containing the vertical scrollbar.
12186
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12187
+ * selector) to the entity containing the vertical scrollbar.
11742
12188
  * @returns The vertical scrollbar entity reference.
11743
12189
  */
11744
12190
  get verticalScrollbar() {
@@ -11821,7 +12267,9 @@ customElements.define('pc-scroll-view', ScrollViewComponentElement);
11821
12267
  * Values are parsed according to the type of the attribute's current value — initially the
11822
12268
  * script's declared default (numbers, booleans, strings, Vec2/3/4, Color, Quat as Euler
11823
12269
  * angles) — and the `asset:`/`entity:`/`vec2:`/`vec3:`/`vec4:`/`color:` prefixes may be used
11824
- * to be explicit.
12270
+ * to be explicit. An `entity:` reference is an entity name — resolved against the nearest
12271
+ * enclosing entity first, then outward, then the document — or a document-wide `#` selector
12272
+ * (`entity:#id`); a bare value is always a name, never an element id.
11825
12273
  * - **The `attributes` JSON attribute**: an object supporting nested structures and attribute
11826
12274
  * names that collide with reserved HTML attribute names (e.g. `title`).
11827
12275
  *
@@ -11837,7 +12285,8 @@ customElements.define('pc-scroll-view', ScrollViewComponentElement);
11837
12285
  *
11838
12286
  * @elementSummary The `<pc-script-instance>` element attaches one script class, named by `name`, to
11839
12287
  * the entity of its parent `<pc-script>`. Its other attributes set script attributes of the same
11840
- * name, and `attributes` takes a JSON object instead. Must be a direct child of `<pc-script>`.
12288
+ * name, and `attributes` takes a JSON object instead. An `entity:` value is an entity name —
12289
+ * write `entity:#id` for an element id. Must be a direct child of `<pc-script>`.
11841
12290
  *
11842
12291
  * @fires {CustomEvent} scriptattributeschange - Fired when the script's attributes change. The
11843
12292
  * `detail` carries the new `attributes` object. Bubbles.
@@ -11857,9 +12306,11 @@ class ScriptInstanceElement extends AsyncElement {
11857
12306
  /**
11858
12307
  * Sets the attributes of the script as an object. Values are converted with the same rules
11859
12308
  * as the `attributes` attribute: `asset:`/`entity:` references and `vec2:`/`vec3:`/`vec4:`/
11860
- * `color:` prefixed strings are resolved, and a plain numeric array is converted to the
11861
- * type of the attribute it targets when that attribute currently holds a Vec2, Vec3, Vec4
11862
- * or Color.
12309
+ * `color:` prefixed strings are resolved (an entity name against the nearest enclosing
12310
+ * entity first, then outward, then the document or a document-wide `#` selector; a bare
12311
+ * value is always a name, never an element id), and a plain numeric array is converted to
12312
+ * the type of the attribute it targets when that attribute currently holds a Vec2, Vec3,
12313
+ * Vec4 or Color.
11863
12314
  * @param value - The attributes of the script.
11864
12315
  */
11865
12316
  set scriptAttributes(value) {
@@ -11870,7 +12321,10 @@ class ScriptInstanceElement extends AsyncElement {
11870
12321
  }));
11871
12322
  }
11872
12323
  /**
11873
- * Gets the attributes of the script.
12324
+ * Gets the attributes of the script as an object whose `asset:`, `entity:`, `vec2:`, `vec3:`,
12325
+ * `vec4:` and `color:` prefixed values are resolved when applied — an `entity:` value being
12326
+ * an entity name (nearest enclosing entity first, then outward, then the document) or a
12327
+ * document-wide `#` selector (`entity:#id`), never a bare element id.
11874
12328
  * @returns The attributes of the script.
11875
12329
  */
11876
12330
  get scriptAttributes() {
@@ -12081,18 +12535,23 @@ const assetConversion = (rest, raw) => {
12081
12535
  return raw;
12082
12536
  };
12083
12537
  /**
12084
- * Resolves an `entity:` prefix to the Entity backing a `pc-entity` element. The reference can be a
12085
- * CSS selector, an element id or an entity name.
12538
+ * Resolves an `entity:` prefix to the Entity backing a `pc-entity`, `pc-model` or `pc-node`
12539
+ * element. The reference is a name resolved against the nearest enclosing entity first, then
12540
+ * outward, then the document — or a document-wide `#` selector. The failure warning names which
12541
+ * of the three causes ({@link unresolvedCause}) it hit.
12086
12542
  * @param rest - The entity reference.
12087
12543
  * @param raw - The raw value, returned unchanged when the reference does not resolve.
12544
+ * @param from - The element the value is declared under, which scopes the reference.
12088
12545
  * @returns The entity, or `raw`.
12089
12546
  */
12090
- const entityConversion = (rest, raw) => {
12091
- const entity = getEntity(rest);
12547
+ const entityConversion = (rest, raw, from) => {
12548
+ const entity = getEntity(rest, from);
12092
12549
  if (entity) {
12093
12550
  return entity;
12094
12551
  }
12095
- console.warn(`Unable to resolve '${raw}' in script attributes - no pc-entity found matching '${rest}'.`);
12552
+ const element = findEntityElement(rest, from);
12553
+ const hint = element ? '' : idHint(rest, 'entity:');
12554
+ console.warn(`Unable to resolve '${raw}' in script attributes - ${unresolvedCause(element)}.${hint ? ` ${hint}` : ''}`);
12096
12555
  return raw;
12097
12556
  };
12098
12557
  /**
@@ -12225,8 +12684,10 @@ class ScriptComponentElement extends ComponentElement {
12225
12684
  /**
12226
12685
  * Recursively converts raw attribute data into proper PlayCanvas types. Supported conversions:
12227
12686
  * - "asset:id" → the Asset created by the `pc-asset` element with that id
12228
- * - "entity:ref" → the Entity backing a `pc-entity` element. The reference can be a CSS
12229
- * selector, an element id or an entity name.
12687
+ * - "entity:ref" → the Entity backing a `pc-entity`, `pc-model` or `pc-node` element. The
12688
+ * reference is a name, resolved against this element's nearest enclosing entity first,
12689
+ * then outward, then the document — or a document-wide `#` selector (`entity:#id`). A bare
12690
+ * value is always a name, never an id.
12230
12691
  * - "vec2:1 2" → new Vec2(1, 2)
12231
12692
  * - "vec3:1 2 3" → new Vec3(1, 2, 3)
12232
12693
  * - "vec4:1 2 3 4" → new Vec4(1, 2, 3, 4)
@@ -12240,7 +12701,7 @@ class ScriptComponentElement extends ComponentElement {
12240
12701
  convertAttributes(item) {
12241
12702
  if (typeof item === 'string') {
12242
12703
  const match = matchConversion(item);
12243
- return match ? match.convert(match.rest, item) : item;
12704
+ return match ? match.convert(match.rest, item, this) : item;
12244
12705
  }
12245
12706
  if (Array.isArray(item)) {
12246
12707
  return item.map((element) => this.convertAttributes(element));
@@ -12618,8 +13079,9 @@ class ScriptComponentElement extends ComponentElement {
12618
13079
  super.disconnectedCallback?.();
12619
13080
  }
12620
13081
  /**
12621
- * Gets the underlying PlayCanvas script component.
12622
- * @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`.
12623
13085
  */
12624
13086
  get component() {
12625
13087
  return super.component;
@@ -12665,8 +13127,9 @@ class SoundComponentElement extends ComponentElement {
12665
13127
  };
12666
13128
  }
12667
13129
  /**
12668
- * Gets the underlying PlayCanvas sound component.
12669
- * @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`.
12670
13133
  */
12671
13134
  get component() {
12672
13135
  return super.component;
@@ -13147,8 +13610,9 @@ class GSplatComponentElement extends ComponentElement {
13147
13610
  };
13148
13611
  }
13149
13612
  /**
13150
- * Gets the underlying PlayCanvas gsplat component.
13151
- * @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`.
13152
13616
  */
13153
13617
  get component() {
13154
13618
  return super.component;
@@ -14441,31 +14905,27 @@ class SkyElement extends AsyncElement {
14441
14905
  _appElement = null;
14442
14906
  /**
14443
14907
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
14444
- * load that resumes from an await or a load callback abandons itself if the value has moved
14445
- * 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.
14446
14911
  */
14447
14912
  _loadGeneration = 0;
14448
14913
  /**
14449
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
14450
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
14451
- * 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.
14452
14916
  */
14453
- _loadHandle = null;
14917
+ _binding = new AssetBinding();
14454
14918
  connectedCallback() {
14455
14919
  this._loadSkybox();
14456
14920
  this._onReady();
14457
14921
  }
14458
14922
  disconnectedCallback() {
14459
14923
  this._loadGeneration++;
14460
- this._detachLoadHandler();
14924
+ this._binding.cancel();
14461
14925
  this._unloadSkybox();
14462
14926
  this._appElement = null;
14463
14927
  this._resetReady();
14464
14928
  }
14465
- _detachLoadHandler() {
14466
- this._loadHandle?.off();
14467
- this._loadHandle = null;
14468
- }
14469
14929
  _generateSkybox(asset) {
14470
14930
  if (!this._scene)
14471
14931
  return;
@@ -14497,7 +14957,7 @@ class SkyElement extends AsyncElement {
14497
14957
  async _loadSkybox() {
14498
14958
  // Supersede any load already in flight - only the newest load may generate the skybox
14499
14959
  const generation = ++this._loadGeneration;
14500
- this._detachLoadHandler();
14960
+ this._binding.cancel();
14501
14961
  const appElement = await this.closestApp?.ready();
14502
14962
  // The element may have been removed, or another load started, while we waited
14503
14963
  if (generation !== this._loadGeneration) {
@@ -14508,26 +14968,18 @@ class SkyElement extends AsyncElement {
14508
14968
  return;
14509
14969
  }
14510
14970
  this._appElement = appElement;
14511
- const asset = useAsset(this._asset);
14512
- 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)) {
14513
14977
  return;
14514
14978
  }
14515
14979
  this._scene = app.scene;
14516
- if (asset.loaded) {
14517
- this._generateSkybox(asset);
14518
- }
14519
- else {
14520
- // The generation is re-checked even though a superseded handler is detached: the
14521
- // detach relies on how the engine's event emitter treats removal, while the check
14522
- // holds on its own.
14523
- this._loadHandle = asset.once('load', () => {
14524
- this._loadHandle = null;
14525
- if (generation !== this._loadGeneration) {
14526
- return;
14527
- }
14528
- this._generateSkybox(asset);
14529
- });
14530
- }
14980
+ this._binding.bind(this._asset, {
14981
+ load: (asset) => this._generateSkybox(asset)
14982
+ });
14531
14983
  }
14532
14984
  _unloadSkybox() {
14533
14985
  const scene = this._scene;