@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.js CHANGED
@@ -588,9 +588,6 @@
588
588
  * shared frozen constants (`Vec3.ZERO`, `Color.WHITE`) as defaults.
589
589
  * - `parseBool` and `parseTags` take no attribute name, because every value is valid for them and
590
590
  * so they never warn.
591
- *
592
- * `getEntity` is the exception: it resolves a reference to a live entity rather than parsing a
593
- * literal, and returns `null` instead of falling back to a default.
594
591
  */
595
592
  /**
596
593
  * Splits an attribute value into exactly `count` numeric components. Returns `null` when the
@@ -836,34 +833,10 @@
836
833
  }
837
834
  return new playcanvas.Vec4(components);
838
835
  };
839
- /**
840
- * Resolves a reference string to the {@link Entity} backing a `<pc-entity>` element. The reference
841
- * can be a CSS selector (e.g. `#my-id`, `pc-entity[name="Foo"]`), a bare element id, or a bare
842
- * entity name. Returns `null` if no matching element (or backing entity) is found.
843
- *
844
- * @param ref - The reference string to resolve.
845
- * @returns The resolved entity, or `null`.
846
- * @internal
847
- */
848
- const getEntity = (ref) => {
849
- if (!ref) {
850
- return null;
851
- }
852
- let element = null;
853
- // Try the reference as a CSS selector. An invalid selector (e.g. a bare name containing
854
- // spaces) throws, in which case we fall back to id/name lookups below.
855
- try {
856
- element = document.querySelector(ref);
857
- }
858
- catch {
859
- element = null;
860
- }
861
- if (!element) {
862
- element = document.getElementById(ref) ?? document.querySelector(`pc-entity[name="${ref}"]`);
863
- }
864
- return element?.entity ?? null;
865
- };
866
836
 
837
+ // Keep `export` on these declarations. TypeScript removes the declaration and its inline export
838
+ // when `stripInternal` is enabled. A separate `export { ... }` statement would remain in the
839
+ // generated .d.ts file and refer to a declaration that had been removed.
867
840
  /**
868
841
  * The event types whose listeners make an element a hover target. Hover resolution walks past
869
842
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -907,8 +880,428 @@
907
880
  return node;
908
881
  }
909
882
  }
910
- return null;
911
- };
883
+ return null;
884
+ };
885
+ /**
886
+ * The pointer-input subsystem of a `<pc-app>` element: it owns the engine {@link Picker}, the
887
+ * canvas pointer handlers, and everything between them - mapping browser coordinates into the
888
+ * drawing buffer, selecting the camera, resolving picked nodes to listening elements, tracking
889
+ * hover, and dispatching the synthesized pointer and click events in canvas-event order.
890
+ *
891
+ * The host drives a small lifecycle: {@link connect} once the application and canvas exist,
892
+ * {@link resize} when the drawing buffer changes size, {@link syncListeners} when a descendant's
893
+ * pointer listeners change, and {@link disconnect} on teardown. Everything else is internal.
894
+ *
895
+ * @internal
896
+ */
897
+ class PointerController {
898
+ _host;
899
+ /**
900
+ * Incremented by every connect and disconnect. Async work captures the value when it starts
901
+ * and stops if it has moved on - so a pick or dispatch belonging to an earlier connection
902
+ * can neither keep reading through its destroyed picker nor deliver into a later
903
+ * connection. The field null checks alone cannot tell the two apart once a reconnect has
904
+ * repopulated them.
905
+ */
906
+ _generation = 0;
907
+ _app = null;
908
+ _canvas = null;
909
+ _picker = null;
910
+ _hoveredEntity = null;
911
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
912
+ _pickToken = 0;
913
+ _pointerHandlers = {
914
+ pointermove: null,
915
+ pointerdown: null,
916
+ pointerup: null,
917
+ pointercancel: null
918
+ };
919
+ /**
920
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
921
+ * may still conclude it. The promise is stored rather than its result, so a release can
922
+ * await a press pick that has not resolved yet. Entries are removed by the matching
923
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
924
+ * which is also what keeps those two canvas listeners attached.
925
+ */
926
+ _downPicks = new Map();
927
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
928
+ _clickListened = false;
929
+ /**
930
+ * The previous click's target, time and count, for chaining successive clicks into the
931
+ * click count that `detail` carries. `null` until a click has fired.
932
+ */
933
+ _lastClick = null;
934
+ /**
935
+ * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
936
+ * whose picks resolve in GPU order, not canvas-event order. Replaced on disconnect, so a
937
+ * pick that never resolves cannot stall the dispatches of a later boot.
938
+ */
939
+ _dispatchChain = Promise.resolve();
940
+ /**
941
+ * @param host - The services the controller reads from its host element.
942
+ */
943
+ constructor(host) {
944
+ this._host = host;
945
+ }
946
+ /**
947
+ * Creates the picker and the canvas handlers for a booted application, and attaches whatever
948
+ * canvas listeners the tree's current element listeners already need (handlers created from
949
+ * inline attributes when their elements were first upgraded, or listeners carried over from
950
+ * before a re-boot).
951
+ *
952
+ * @param app - The application to pick against.
953
+ * @param canvas - The canvas the application renders into.
954
+ */
955
+ connect(app, canvas) {
956
+ this._generation++;
957
+ this._app = app;
958
+ this._canvas = canvas;
959
+ const { width, height } = app.graphicsDevice;
960
+ this._picker = new playcanvas.Picker(app, width, height);
961
+ // Create bound handlers but don't attach them yet. The move handler is async, so it is
962
+ // wrapped to discard the promise - a listener must not return one.
963
+ const listener = (handler) => {
964
+ return (event) => {
965
+ handler.call(this, event);
966
+ };
967
+ };
968
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
969
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
970
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
971
+ this._pointerHandlers.pointercancel = (event) => {
972
+ this._downPicks.delete(event.pointerId);
973
+ };
974
+ this.syncListeners();
975
+ }
976
+ /**
977
+ * Detaches the canvas listeners and drops every piece of pointer state, so nothing picked or
978
+ * queued before the teardown can affect a later boot. Safe to call on a controller that was
979
+ * never connected.
980
+ */
981
+ disconnect() {
982
+ this._generation++;
983
+ if (this._canvas) {
984
+ Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
985
+ if (handler) {
986
+ this._canvas.removeEventListener(type, handler);
987
+ }
988
+ });
989
+ }
990
+ this._app = null;
991
+ this._canvas = null;
992
+ this._picker = null;
993
+ this._hoveredEntity = null;
994
+ this._pointerHandlers = {
995
+ pointermove: null,
996
+ pointerdown: null,
997
+ pointerup: null,
998
+ pointercancel: null
999
+ };
1000
+ this._downPicks.clear();
1001
+ this._clickListened = false;
1002
+ this._lastClick = null;
1003
+ // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
1004
+ this._dispatchChain = Promise.resolve();
1005
+ }
1006
+ /**
1007
+ * Resizes the picker to the drawing buffer. The picker must track the buffer, or picks would
1008
+ * land at stale coordinates after a resize.
1009
+ *
1010
+ * @param width - The drawing buffer width.
1011
+ * @param height - The drawing buffer height.
1012
+ */
1013
+ resize(width, height) {
1014
+ this._picker?.resize(width, height);
1015
+ }
1016
+ /**
1017
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
1018
+ * detaches the rest. Called whenever a listener connects or disconnects anywhere under the
1019
+ * host element: several synthesized types can need the same canvas listener (enter, leave
1020
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
1021
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
1022
+ * a no-op by EventTarget semantics, so no attach state is kept. Does nothing before
1023
+ * {@link connect} - connecting syncs once the handlers exist.
1024
+ */
1025
+ syncListeners() {
1026
+ const canvas = this._canvas;
1027
+ if (!canvas)
1028
+ return;
1029
+ const elements = this._host.pointerTargets();
1030
+ const needed = new Set();
1031
+ for (const type of SYNTHESIZED_EVENTS) {
1032
+ if (elements.some((element) => element._hasListeners(type))) {
1033
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1034
+ }
1035
+ }
1036
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1037
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1038
+ if (!handler)
1039
+ return;
1040
+ if (needed.has(canvasType)) {
1041
+ canvas.addEventListener(canvasType, handler);
1042
+ }
1043
+ else {
1044
+ canvas.removeEventListener(canvasType, handler);
1045
+ }
1046
+ });
1047
+ }
1048
+ /**
1049
+ * Resolves the element that owns hover for a picked node: the nearest node up the parent
1050
+ * chain - starting with the node itself - whose element listens for any of the hover event
1051
+ * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1052
+ * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1053
+ * to hover rather than swallowing a listening ancestor's enter/leave pair.
1054
+ *
1055
+ * @param node - The picked node, or `null`.
1056
+ * @returns The hover-owning element, or `null`.
1057
+ */
1058
+ _hoverTarget(node) {
1059
+ while (node !== null) {
1060
+ const element = this._host.elementFromNode(node);
1061
+ if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1062
+ return element;
1063
+ }
1064
+ node = node.parent;
1065
+ }
1066
+ return null;
1067
+ }
1068
+ /**
1069
+ * Like {@link _hoverTarget}, but for one event type: skips elements without a listener for
1070
+ * `type`, so a hit on an unlistened child still reaches a listening ancestor.
1071
+ *
1072
+ * @param node - The picked node, or `null`.
1073
+ * @param type - The pointer event type a listener is required for.
1074
+ * @returns The nearest listening element, or `null`.
1075
+ */
1076
+ _elementWithListener(node, type) {
1077
+ while (node !== null) {
1078
+ const element = this._host.elementFromNode(node);
1079
+ if (element?._hasListeners(type)) {
1080
+ return element;
1081
+ }
1082
+ node = node.parent;
1083
+ }
1084
+ return null;
1085
+ }
1086
+ /**
1087
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1088
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1089
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1090
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1091
+ * coordinates correspond to no real geometry.
1092
+ *
1093
+ * @param event - The pointer event to convert.
1094
+ * @param canvas - The canvas the event was dispatched on.
1095
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
1096
+ */
1097
+ _getPickerCoordinates(event, canvas) {
1098
+ const canvasRect = canvas.getBoundingClientRect();
1099
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
1100
+ return { x: event.clientX, y: event.clientY, mapped: false };
1101
+ }
1102
+ const scaleX = canvas.width / canvasRect.width;
1103
+ const scaleY = canvas.height / canvasRect.height;
1104
+ return {
1105
+ x: (event.clientX - canvasRect.left) * scaleX,
1106
+ y: (event.clientY - canvasRect.top) * scaleY,
1107
+ mapped: true
1108
+ };
1109
+ }
1110
+ /**
1111
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
1112
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1113
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1114
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1115
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1116
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1117
+ * holds nothing there.
1118
+ *
1119
+ * @param camera - The camera to test.
1120
+ * @param x - The x coordinate, in buffer space.
1121
+ * @param y - The y coordinate, in buffer space.
1122
+ * @param canvas - The canvas the coordinates are relative to.
1123
+ * @returns Whether the camera's viewport contains the point.
1124
+ */
1125
+ _cameraContains(camera, x, y, canvas) {
1126
+ const rect = camera.rect;
1127
+ const left = rect.x * canvas.width;
1128
+ const bottom = (1 - rect.y) * canvas.height;
1129
+ const top = bottom - rect.w * canvas.height;
1130
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1131
+ }
1132
+ /**
1133
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1134
+ *
1135
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1136
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1137
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1138
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
1139
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1140
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1141
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
1142
+ *
1143
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1144
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
1145
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1146
+ * async variant works on both backends and does not block the main thread on a GPU read.
1147
+ *
1148
+ * @param event - The pointer event to pick under.
1149
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
1150
+ */
1151
+ async _pickNode(event) {
1152
+ const generation = this._generation;
1153
+ const app = this._app;
1154
+ const picker = this._picker;
1155
+ const canvas = this._canvas;
1156
+ if (!app || !picker || !canvas)
1157
+ return null;
1158
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1159
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
1160
+ // renders last and sits on top. Read through .at() because a pick handler may remove
1161
+ // cameras while an earlier iteration's read back is in flight.
1162
+ const cameras = app.systems.camera?.cameras ?? [];
1163
+ for (let i = cameras.length - 1; i >= 0; i--) {
1164
+ const camera = cameras.at(i);
1165
+ // A camera rendering to a texture is not on the canvas.
1166
+ if (!camera || camera.renderTarget)
1167
+ continue;
1168
+ // Coordinates that could not be mapped cannot be tested for containment.
1169
+ if (mapped && !this._cameraContains(camera, x, y, canvas))
1170
+ continue;
1171
+ picker.prepare(camera, app.scene);
1172
+ const selection = await picker.getSelectionAsync(x, y);
1173
+ // The host may have disconnected - or disconnected and reconnected - while the read
1174
+ // back was in flight. Either way this pick's connection is gone.
1175
+ if (generation !== this._generation)
1176
+ return null;
1177
+ if (selection.length > 0) {
1178
+ const item = selection[0];
1179
+ return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1180
+ }
1181
+ // Nothing hit. A camera that clears the color buffer paints its background over
1182
+ // everything beneath it, so the miss is final; one that does not is an overlay
1183
+ // that the cameras beneath show through, so they get their turn.
1184
+ if (camera.clearColorBuffer)
1185
+ return null;
1186
+ }
1187
+ return null;
1188
+ }
1189
+ async _onPointerMove(event) {
1190
+ if (!this._picker || !this._app)
1191
+ return;
1192
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1193
+ // newest pick may update the hover state - an older one describes a pointer position the
1194
+ // user has already left, and one from an earlier connection describes a scene that no
1195
+ // longer exists.
1196
+ const generation = this._generation;
1197
+ const token = ++this._pickToken;
1198
+ const node = await this._pickNode(event);
1199
+ if (token !== this._pickToken || generation !== this._generation)
1200
+ return;
1201
+ // The hovered element is the nearest one up the node's parent chain with a hover
1202
+ // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1203
+ // type below: having any hover listener selects the target, each event needs its own.
1204
+ const newHoverEntity = this._hoverTarget(node);
1205
+ // Handle enter/leave events
1206
+ if (this._hoveredEntity !== newHoverEntity) {
1207
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1208
+ this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1209
+ }
1210
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1211
+ newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1212
+ }
1213
+ }
1214
+ // Update hover state
1215
+ this._hoveredEntity = newHoverEntity;
1216
+ // Handle pointermove event
1217
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1218
+ newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
1223
+ * canvas event handler - the order of appends is what carries canvas-event order. A step
1224
+ * that rejects is reported and released, so the steps queued behind it still dispatch.
1225
+ *
1226
+ * @param step - The dispatch work to run once every earlier step has finished.
1227
+ */
1228
+ _chainDispatch(step) {
1229
+ this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
1230
+ console.error(error);
1231
+ });
1232
+ }
1233
+ _onPointerDown(event) {
1234
+ if (!this._picker || !this._app)
1235
+ return;
1236
+ const generation = this._generation;
1237
+ // Picks stay concurrent - only the dispatch of the results is serialized
1238
+ const pick = this._pickNode(event);
1239
+ // A click concludes on the matching pointerup, which needs to know what the press
1240
+ // picked. Primary button only - the only button a click can conclude from - and only
1241
+ // while click is listened for, since it is the click mapping that keeps the pointerup
1242
+ // and pointercancel listeners attached to clean the entry up again.
1243
+ if (this._clickListened && event.button === 0) {
1244
+ this._downPicks.set(event.pointerId, pick);
1245
+ }
1246
+ this._chainDispatch(async () => {
1247
+ const node = await pick;
1248
+ if (generation !== this._generation)
1249
+ return; // this press's connection is gone
1250
+ const entityElement = this._elementWithListener(node, 'pointerdown');
1251
+ if (entityElement) {
1252
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1253
+ }
1254
+ });
1255
+ }
1256
+ _onPointerUp(event) {
1257
+ if (!this._picker || !this._app)
1258
+ return;
1259
+ const generation = this._generation;
1260
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
1261
+ // entry is gone before any other event for this pointer can be handled.
1262
+ const downPick = this._downPicks.get(event.pointerId);
1263
+ this._downPicks.delete(event.pointerId);
1264
+ const pick = this._pickNode(event);
1265
+ this._chainDispatch(async () => {
1266
+ const node = await pick;
1267
+ if (generation !== this._generation)
1268
+ return; // this release's connection is gone
1269
+ const entityElement = this._elementWithListener(node, 'pointerup');
1270
+ if (entityElement) {
1271
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1272
+ }
1273
+ });
1274
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1275
+ // what the press and the release picked, for the primary button only. Appended after
1276
+ // the release's own step, so it dispatches after the pointerup that concludes it.
1277
+ if (!downPick || event.button !== 0)
1278
+ return;
1279
+ this._chainDispatch(async () => {
1280
+ // A rejected pick was already reported by the press or release step that awaited it;
1281
+ // here it just means no click can conclude.
1282
+ const picked = await Promise.all([downPick, pick]).catch(() => null);
1283
+ if (!picked || generation !== this._generation)
1284
+ return;
1285
+ const [downNode, upNode] = picked;
1286
+ const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
1287
+ if (clickElement) {
1288
+ const click = new PointerEvent('click', event);
1289
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1290
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
1291
+ // platform chains it (same target, within the double-click window). Overridden
1292
+ // with defineProperty because an event instance used as an init dict cannot have
1293
+ // single fields replaced.
1294
+ const time = performance.now();
1295
+ const last = this._lastClick;
1296
+ const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1297
+ this._lastClick = { element: clickElement, time, count };
1298
+ Object.defineProperty(click, 'detail', { value: count });
1299
+ clickElement.dispatchEvent(click);
1300
+ }
1301
+ });
1302
+ }
1303
+ }
1304
+
912
1305
  /**
913
1306
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
914
1307
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -992,31 +1385,16 @@
992
1385
  * engine scene nodes back to their owning elements by identity - never by name.
993
1386
  */
994
1387
  _entityElements = new Map();
995
- _picker = null;
996
- _hoveredEntity = null;
997
- // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
998
- _pickToken = 0;
999
- _pointerHandlers = {
1000
- pointermove: null,
1001
- pointerdown: null,
1002
- pointerup: null,
1003
- pointercancel: null
1004
- };
1005
- /**
1006
- * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
1007
- * may still conclude it. The promise is stored rather than its result, so a release can
1008
- * await a press pick that has not resolved yet. Entries are removed by the matching
1009
- * pointerup or pointercancel, and only ever stored while some element listens for click -
1010
- * which is also what keeps those two canvas listeners attached.
1011
- */
1012
- _downPicks = new Map();
1013
- /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
1014
- _clickListened = false;
1015
1388
  /**
1016
- * The previous click's target, time and count, for chaining successive clicks into the
1017
- * click count that `detail` carries. `null` until a click has fired.
1389
+ * The pointer-input subsystem: the picker, the canvas handlers, and the synthesized-event
1390
+ * dispatch. The element drives its lifecycle (connect on boot, resize with the drawing
1391
+ * buffer, listener syncs, disconnect on teardown) and hands it the two lookups it needs -
1392
+ * everything else about pointer input lives in the controller.
1018
1393
  */
1019
- _lastClick = null;
1394
+ _pointer = new PointerController({
1395
+ elementFromNode: (node) => this._entityElements.get(node) ?? null,
1396
+ pointerTargets: () => Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'))
1397
+ });
1020
1398
  _app = null;
1021
1399
  _loadProgress = 0;
1022
1400
  /**
@@ -1055,8 +1433,8 @@
1055
1433
  // entities. Registered once here rather than on every boot - the sync no-ops while there
1056
1434
  // is no canvas, and a re-booted element must not stack a second set.
1057
1435
  SYNTHESIZED_EVENTS.forEach((type) => {
1058
- this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1059
- this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
1436
+ this.addEventListener(`${type}:connect`, () => this._pointer.syncListeners());
1437
+ this.addEventListener(`${type}:disconnect`, () => this._pointer.syncListeners());
1060
1438
  });
1061
1439
  }
1062
1440
  async connectedCallback() {
@@ -1230,7 +1608,7 @@
1230
1608
  // from the canvas's client size
1231
1609
  app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
1232
1610
  app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
1233
- this._pickerCreate();
1611
+ this._pointer.connect(app, this._canvas);
1234
1612
  // Track the element's box rather than the window: containers resize without any window
1235
1613
  // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1236
1614
  // ResizeObserver - there, the resolution set above is the only sizing that happens.
@@ -1322,7 +1700,7 @@
1322
1700
  // instead of completing against a torn-down element.
1323
1701
  this._bootGeneration++;
1324
1702
  this._optionsLocked = false;
1325
- this._pickerDestroy();
1703
+ this._pointer.disconnect();
1326
1704
  // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1327
1705
  // unregister them - clear() covers any entity the engine no longer reached.
1328
1706
  if (this._app) {
@@ -1358,49 +1736,7 @@
1358
1736
  }
1359
1737
  this.app.updateCanvasSize();
1360
1738
  const { width, height } = this.app.graphicsDevice;
1361
- this._picker?.resize(width, height);
1362
- }
1363
- _pickerCreate() {
1364
- const { width, height } = this.app.graphicsDevice;
1365
- this._picker = new playcanvas.Picker(this.app, width, height);
1366
- // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
1367
- // each is wrapped to discard the promise - a listener must not return one, and nothing
1368
- // awaits the result.
1369
- const listener = (handler) => {
1370
- return (event) => {
1371
- handler.call(this, event);
1372
- };
1373
- };
1374
- this._pointerHandlers.pointermove = listener(this._onPointerMove);
1375
- this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1376
- this._pointerHandlers.pointerup = listener(this._onPointerUp);
1377
- this._pointerHandlers.pointercancel = (event) => {
1378
- this._downPicks.delete(event.pointerId);
1379
- };
1380
- // Attach canvas listeners for element listeners registered before this boot (e.g.
1381
- // handlers created from inline attributes when their elements were first upgraded, or
1382
- // listeners carried over from before a re-boot)
1383
- this._syncCanvasListeners();
1384
- }
1385
- _pickerDestroy() {
1386
- if (this._canvas) {
1387
- Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
1388
- if (handler) {
1389
- this._canvas.removeEventListener(type, handler);
1390
- }
1391
- });
1392
- }
1393
- this._picker = null;
1394
- this._hoveredEntity = null;
1395
- this._pointerHandlers = {
1396
- pointermove: null,
1397
- pointerdown: null,
1398
- pointerup: null,
1399
- pointercancel: null
1400
- };
1401
- this._downPicks.clear();
1402
- this._clickListened = false;
1403
- this._lastClick = null;
1739
+ this._pointer.resize(width, height);
1404
1740
  }
1405
1741
  /**
1406
1742
  * Registers the element that fronts an entity. Called by EntityElement when it creates its
@@ -1428,268 +1764,11 @@
1428
1764
  * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1429
1765
  * through the engine API.
1430
1766
  *
1431
- * @param entity - The entity to look up.
1432
- * @returns The element fronting the entity, or `null`.
1433
- */
1434
- elementFromEntity(entity) {
1435
- return this._entityElements.get(entity) ?? null;
1436
- }
1437
- /**
1438
- * Resolves the element that owns hover for a picked node: the nearest node up the parent
1439
- * chain - starting with the node itself - whose element listens for any of the hover event
1440
- * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1441
- * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1442
- * to hover rather than swallowing a listening ancestor's enter/leave pair.
1443
- *
1444
- * @param node - The picked node, or `null`.
1445
- * @returns The hover-owning element, or `null`.
1446
- */
1447
- _hoverTarget(node) {
1448
- while (node !== null) {
1449
- const element = this._entityElements.get(node);
1450
- if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1451
- return element;
1452
- }
1453
- node = node.parent;
1454
- }
1455
- return null;
1456
- }
1457
- /**
1458
- * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1459
- * on an unlistened child still reaches a listening ancestor.
1460
- *
1461
- * @param node - The picked node, or `null`.
1462
- * @param type - The pointer event type a listener is required for.
1463
- * @returns The nearest listening element, or `null`.
1464
- */
1465
- _elementWithListener(node, type) {
1466
- while (node !== null) {
1467
- const element = this._entityElements.get(node);
1468
- if (element?._hasListeners(type)) {
1469
- return element;
1470
- }
1471
- node = node.parent;
1472
- }
1473
- return null;
1474
- }
1475
- /**
1476
- * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1477
- * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1478
- * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1479
- * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1480
- * coordinates correspond to no real geometry.
1481
- *
1482
- * @param event - The pointer event to convert.
1483
- * @param canvas - The canvas the event was dispatched on.
1484
- * @returns The buffer-space coordinates, and whether they were actually mapped.
1485
- */
1486
- _getPickerCoordinates(event, canvas) {
1487
- const canvasRect = canvas.getBoundingClientRect();
1488
- if (canvasRect.width === 0 || canvasRect.height === 0) {
1489
- return { x: event.clientX, y: event.clientY, mapped: false };
1490
- }
1491
- const scaleX = canvas.width / canvasRect.width;
1492
- const scaleY = canvas.height / canvasRect.height;
1493
- return {
1494
- x: (event.clientX - canvasRect.left) * scaleX,
1495
- y: (event.clientY - canvasRect.top) * scaleY,
1496
- mapped: true
1497
- };
1498
- }
1499
- /**
1500
- * Whether a camera's viewport contains the point. A camera renders into its normalized
1501
- * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1502
- * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1503
- * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1504
- * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1505
- * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1506
- * holds nothing there.
1507
- *
1508
- * @param camera - The camera to test.
1509
- * @param x - The x coordinate, in buffer space.
1510
- * @param y - The y coordinate, in buffer space.
1511
- * @param canvas - The canvas the coordinates are relative to.
1512
- * @returns Whether the camera's viewport contains the point.
1513
- */
1514
- _cameraContains(camera, x, y, canvas) {
1515
- const rect = camera.rect;
1516
- const left = rect.x * canvas.width;
1517
- const bottom = (1 - rect.y) * canvas.height;
1518
- const top = bottom - rect.w * canvas.height;
1519
- return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1520
- }
1521
- /**
1522
- * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1523
- *
1524
- * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1525
- * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1526
- * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1527
- * contain the pointer. A camera that picks nothing ends the search if it clears the color
1528
- * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1529
- * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1530
- * The pick buffer is prepared per camera, so each camera picks from its own layers.
1531
- *
1532
- * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1533
- * supported on WebGPU, where it returns an empty selection rather than failing - which
1534
- * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1535
- * async variant works on both backends and does not block the main thread on a GPU read.
1536
- *
1537
- * @param event - The pointer event to pick under.
1538
- * @returns The graph node under the pointer, or `null` if nothing was hit.
1539
- */
1540
- async _pickNode(event) {
1541
- const app = this.app;
1542
- const picker = this._picker;
1543
- const canvas = this._canvas;
1544
- if (!app || !picker || !canvas)
1545
- return null;
1546
- const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1547
- // Walked from the end: the array is sorted by ascending priority, so the last camera
1548
- // renders last and sits on top. Read through .at() because a pick handler may remove
1549
- // cameras while an earlier iteration's read back is in flight.
1550
- const cameras = app.systems.camera?.cameras ?? [];
1551
- for (let i = cameras.length - 1; i >= 0; i--) {
1552
- const camera = cameras.at(i);
1553
- // A camera rendering to a texture is not on the canvas.
1554
- if (!camera || camera.renderTarget)
1555
- continue;
1556
- // Coordinates that could not be mapped cannot be tested for containment.
1557
- if (mapped && !this._cameraContains(camera, x, y, canvas))
1558
- continue;
1559
- picker.prepare(camera, app.scene);
1560
- const selection = await picker.getSelectionAsync(x, y);
1561
- // The element may have disconnected while the read back was in flight.
1562
- if (!this._picker || !this.app)
1563
- return null;
1564
- if (selection.length > 0) {
1565
- const item = selection[0];
1566
- return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1567
- }
1568
- // Nothing hit. A camera that clears the color buffer paints its background over
1569
- // everything beneath it, so the miss is final; one that does not is an overlay
1570
- // that the cameras beneath show through, so they get their turn.
1571
- if (camera.clearColorBuffer)
1572
- return null;
1573
- }
1574
- return null;
1575
- }
1576
- async _onPointerMove(event) {
1577
- if (!this._picker || !this.app)
1578
- return;
1579
- // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1580
- // newest pick may update the hover state - an older one describes a pointer position the
1581
- // user has already left.
1582
- const token = ++this._pickToken;
1583
- const node = await this._pickNode(event);
1584
- if (token !== this._pickToken || !this._picker)
1585
- return;
1586
- // The hovered element is the nearest one up the node's parent chain with a hover
1587
- // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1588
- // type below: having any hover listener selects the target, each event needs its own.
1589
- const newHoverEntity = this._hoverTarget(node);
1590
- // Handle enter/leave events
1591
- if (this._hoveredEntity !== newHoverEntity) {
1592
- if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1593
- this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1594
- }
1595
- if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1596
- newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1597
- }
1598
- }
1599
- // Update hover state
1600
- this._hoveredEntity = newHoverEntity;
1601
- // Handle pointermove event
1602
- if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1603
- newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1604
- }
1605
- }
1606
- async _onPointerDown(event) {
1607
- if (!this._picker || !this.app)
1608
- return;
1609
- const pick = this._pickNode(event);
1610
- // A click concludes on the matching pointerup, which needs to know what the press
1611
- // picked. Primary button only - the only button a click can conclude from - and only
1612
- // while click is listened for, since it is the click mapping that keeps the pointerup
1613
- // and pointercancel listeners attached to clean the entry up again.
1614
- if (this._clickListened && event.button === 0) {
1615
- this._downPicks.set(event.pointerId, pick);
1616
- }
1617
- const node = await pick;
1618
- if (!this._picker)
1619
- return; // the element disconnected while the pick was in flight
1620
- const entityElement = this._elementWithListener(node, 'pointerdown');
1621
- if (entityElement) {
1622
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1623
- }
1624
- }
1625
- async _onPointerUp(event) {
1626
- if (!this._picker || !this.app)
1627
- return;
1628
- // The press pick this release may conclude as a click. Claimed synchronously, so the
1629
- // entry is gone before any other event for this pointer can be handled.
1630
- const downPick = this._downPicks.get(event.pointerId);
1631
- this._downPicks.delete(event.pointerId);
1632
- const node = await this._pickNode(event);
1633
- if (!this._picker)
1634
- return; // the element disconnected while the pick was in flight
1635
- const entityElement = this._elementWithListener(node, 'pointerup');
1636
- if (entityElement) {
1637
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1638
- }
1639
- // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1640
- // what the press and the release picked, for the primary button only. The press pick
1641
- // may still be in flight - a quick tap resolves in pick order, not event order.
1642
- if (!downPick || event.button !== 0)
1643
- return;
1644
- const downNode = await downPick;
1645
- if (!this._picker)
1646
- return;
1647
- const clickElement = this._elementWithListener(commonAncestor(downNode, node), 'click');
1648
- if (clickElement) {
1649
- const click = new PointerEvent('click', event);
1650
- // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1651
- // at 0 - but click is exempt: its detail is the click count, chained here as the
1652
- // platform chains it (same target, within the double-click window). Overridden
1653
- // with defineProperty because an event instance used as an init dict cannot have
1654
- // single fields replaced.
1655
- const time = performance.now();
1656
- const last = this._lastClick;
1657
- const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1658
- this._lastClick = { element: clickElement, time, count };
1659
- Object.defineProperty(click, 'detail', { value: count });
1660
- clickElement.dispatchEvent(click);
1661
- }
1662
- }
1663
- /**
1664
- * Attaches exactly the canvas listeners the tree's current element listeners need, and
1665
- * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1666
- * this element: several synthesized types can need the same canvas listener (enter, leave
1667
- * and move all ride the move pick; click rides the down/up pair), so one type's removal
1668
- * must not detach a listener another type still uses. Re-attaching an attached listener is
1669
- * a no-op by EventTarget semantics, so no attach state is kept.
1767
+ * @param entity - The entity to look up.
1768
+ * @returns The element fronting the entity, or `null`.
1670
1769
  */
1671
- _syncCanvasListeners() {
1672
- const canvas = this._canvas;
1673
- if (!canvas)
1674
- return; // not booted yet: _pickerCreate syncs once the handlers exist
1675
- const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1676
- const needed = new Set();
1677
- for (const type of SYNTHESIZED_EVENTS) {
1678
- if (elements.some((element) => element._hasListeners(type))) {
1679
- canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1680
- }
1681
- }
1682
- this._clickListened = elements.some((element) => element._hasListeners('click'));
1683
- Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1684
- if (!handler)
1685
- return;
1686
- if (needed.has(canvasType)) {
1687
- canvas.addEventListener(canvasType, handler);
1688
- }
1689
- else {
1690
- canvas.removeEventListener(canvasType, handler);
1691
- }
1692
- });
1770
+ elementFromEntity(entity) {
1771
+ return this._entityElements.get(entity) ?? null;
1693
1772
  }
1694
1773
  /**
1695
1774
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -3091,6 +3170,108 @@
3091
3170
  return asset;
3092
3171
  };
3093
3172
 
3173
+ /**
3174
+ * Watches the asset currently selected by an element.
3175
+ *
3176
+ * An element can change its asset while the old one is still loading, or disconnect before the
3177
+ * load finishes. This class makes sure callbacks from those older loads do nothing. Calling
3178
+ * {@link bind} stops watching the previous asset and starts watching the new one. Calling
3179
+ * {@link cancel} stops watching altogether.
3180
+ *
3181
+ * Asset lookup goes through {@link useAsset}, so selecting a lazy asset starts its load. The
3182
+ * caller still decides what to do with the result, such as creating scene content, reporting an
3183
+ * error, or marking an element ready.
3184
+ *
3185
+ * Reuse one `AssetBinding` for each asset-valued property throughout the element's lifetime. It
3186
+ * is safe for a callback to call `bind` again: the new asset remains active after the callback
3187
+ * returns.
3188
+ *
3189
+ * @internal
3190
+ */
3191
+ class AssetBinding {
3192
+ /**
3193
+ * Each bind or cancel gets a new number. Event handlers remember the number they were created
3194
+ * with and return if it is no longer current. Old listeners are normally removed as well, but
3195
+ * this check also protects against an event that was already in progress when removal
3196
+ * happened.
3197
+ */
3198
+ _generation = 0;
3199
+ _loadHandle = null;
3200
+ _errorHandle = null;
3201
+ _detach() {
3202
+ this._loadHandle?.off();
3203
+ this._loadHandle = null;
3204
+ this._errorHandle?.off();
3205
+ this._errorHandle = null;
3206
+ }
3207
+ /**
3208
+ * Stops watching the current asset and prevents its callbacks from running. The binding can
3209
+ * be used again by calling {@link bind}.
3210
+ */
3211
+ cancel() {
3212
+ this._generation++;
3213
+ this._detach();
3214
+ }
3215
+ /**
3216
+ * Starts watching the asset registered under `id` and stops watching the previous one. A
3217
+ * missing `id` still clears the previous binding. Looking up a lazy asset starts its load.
3218
+ *
3219
+ * If the asset has already loaded successfully, `load` runs before this method returns. An
3220
+ * earlier failure calls `error` immediately when that callback is provided; without one, the
3221
+ * binding waits for a later successful reload. Assets still loading are watched for the same
3222
+ * two outcomes. Once an event is handled, both listeners are removed.
3223
+ *
3224
+ * @param id - The `id` of the `<pc-asset>` element to bind to.
3225
+ * @param callbacks - Functions to call when loading succeeds or fails.
3226
+ * @returns The selected asset, or `undefined` if no asset has this `id`. The caller decides
3227
+ * how to handle a missing asset.
3228
+ */
3229
+ bind(id, callbacks) {
3230
+ const generation = ++this._generation;
3231
+ this._detach();
3232
+ const asset = useAsset(id);
3233
+ if (!asset) {
3234
+ return undefined;
3235
+ }
3236
+ const { error } = callbacks;
3237
+ if (asset.loaded) {
3238
+ // PlayCanvas sets `loaded` after both success and failure, so a resource must also be
3239
+ // present before this counts as success. Use `!= null` deliberately: `undefined` and
3240
+ // `null` both mean there is no resource, while a valid resource can still be falsy
3241
+ // (for example, an empty text file produces '').
3242
+ if (asset.resource != null) {
3243
+ callbacks.load(asset);
3244
+ return asset;
3245
+ }
3246
+ if (error) {
3247
+ error(`asset '${id}' failed to load`);
3248
+ return asset;
3249
+ }
3250
+ }
3251
+ // Old listeners are normally removed by bind or cancel. The number check is a second
3252
+ // safeguard for a late event. Check it before _detach so an old callback cannot remove
3253
+ // the listeners for the current asset. Whichever current event runs first removes both
3254
+ // listeners.
3255
+ this._loadHandle = asset.once('load', () => {
3256
+ if (generation !== this._generation) {
3257
+ return;
3258
+ }
3259
+ this._detach();
3260
+ callbacks.load(asset);
3261
+ });
3262
+ if (error) {
3263
+ this._errorHandle = asset.once('error', (err) => {
3264
+ if (generation !== this._generation) {
3265
+ return;
3266
+ }
3267
+ this._detach();
3268
+ error(err);
3269
+ });
3270
+ }
3271
+ return asset;
3272
+ }
3273
+ }
3274
+
3094
3275
  /**
3095
3276
  * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
3096
3277
  * name is shared by several nodes in the model, the attached component types, and the material
@@ -3191,18 +3372,18 @@
3191
3372
  _contentEntity = null;
3192
3373
  /**
3193
3374
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
3194
- * by a load when it starts. A load that resumes from an await or a load callback abandons
3195
- * itself if the value has moved on, so a superseded load can neither instantiate a second
3196
- * content root nor parent one under a host a newer cycle has already replaced.
3375
+ * by a load when it starts. A load that resumes from an await abandons itself if the value
3376
+ * has moved on, so a superseded load can neither instantiate a second content root nor
3377
+ * parent one under a host a newer cycle has already replaced. The asset subscription itself
3378
+ * is guarded by the binding below.
3197
3379
  */
3198
3380
  _loadGeneration = 0;
3199
3381
  /**
3200
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
3201
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
3202
- * leave them registered until the asset settles (or forever, if it never does).
3382
+ * The subscription to the current container asset while it is loading. Whatever supersedes
3383
+ * the load a newer load, a disconnect, the host dying — cancels it, so the asset settling
3384
+ * later cannot deliver to a load that no longer owns the element.
3203
3385
  */
3204
- _loadHandle = null;
3205
- _errorHandle = null;
3386
+ _binding = new AssetBinding();
3206
3387
  /**
3207
3388
  * The root entity of the instantiated model content, parented beneath the host entity.
3208
3389
  * `null` until the container asset has loaded and been instantiated, after a failed load,
@@ -3293,7 +3474,7 @@
3293
3474
  // resets the element. The generation guard comes first so a load suspended on an await
3294
3475
  // cannot resume against the torn-down element.
3295
3476
  this._loadGeneration++;
3296
- this._detachLoadHandlers();
3477
+ this._binding.cancel();
3297
3478
  this._entity?.destroy();
3298
3479
  }
3299
3480
  /**
@@ -3312,16 +3493,10 @@
3312
3493
  */
3313
3494
  _onEntityDestroy(entity) {
3314
3495
  this._loadGeneration++;
3315
- this._detachLoadHandlers();
3496
+ this._binding.cancel();
3316
3497
  this._contentEntity = null;
3317
3498
  super._onEntityDestroy(entity);
3318
3499
  }
3319
- _detachLoadHandlers() {
3320
- this._loadHandle?.off();
3321
- this._loadHandle = null;
3322
- this._errorHandle?.off();
3323
- this._errorHandle = null;
3324
- }
3325
3500
  /**
3326
3501
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
3327
3502
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -3348,7 +3523,7 @@
3348
3523
  this._destroyContent();
3349
3524
  // Supersede any load already in flight - only the newest load may instantiate
3350
3525
  const generation = ++this._loadGeneration;
3351
- this._detachLoadHandlers();
3526
+ this._binding.cancel();
3352
3527
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
3353
3528
  // content. A no-op on first connection, where readiness is still pending.
3354
3529
  this._resetReady();
@@ -3374,39 +3549,23 @@
3374
3549
  this._onReady();
3375
3550
  return;
3376
3551
  }
3377
- const asset = useAsset(this._asset);
3378
- if (!asset) {
3379
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3380
- // silently pending.
3381
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3382
- return;
3383
- }
3384
- if (asset.loaded) {
3385
- this._instantiate(asset.resource);
3386
- }
3387
- else {
3388
- // The generation is re-checked even though a superseded handler is detached: the
3389
- // detach relies on how the engine's event emitter treats removal, while the check
3390
- // holds on its own. Whichever of load/error fires first detaches the other.
3391
- this._loadHandle = asset.once('load', () => {
3392
- this._detachLoadHandlers();
3393
- if (generation !== this._loadGeneration) {
3394
- return;
3395
- }
3396
- this._instantiate(asset.resource);
3397
- });
3398
- this._errorHandle = asset.once('error', (err) => {
3399
- this._detachLoadHandlers();
3400
- if (generation !== this._loadGeneration) {
3401
- return;
3402
- }
3552
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
3553
+ // delivery below is always current - no generation re-check needed in the callbacks.
3554
+ const asset = this._binding.bind(this._asset, {
3555
+ load: ({ resource }) => this._instantiate(resource),
3556
+ error: (err) => {
3403
3557
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
3404
3558
  // readiness means the load settled, not that it succeeded.
3405
3559
  this.dispatchEvent(new ErrorEvent('error', {
3406
3560
  message: err instanceof Error ? err.message : String(err)
3407
3561
  }));
3408
3562
  this._onReady();
3409
- });
3563
+ }
3564
+ });
3565
+ if (!asset) {
3566
+ // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3567
+ // silently pending.
3568
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3410
3569
  }
3411
3570
  }
3412
3571
  /**
@@ -3541,6 +3700,8 @@
3541
3700
  console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
3542
3701
  return;
3543
3702
  }
3703
+ // The name passed by the subclass selects the engine system that creates its T - a
3704
+ // pairing the type system cannot express, so it is asserted this once
3544
3705
  this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
3545
3706
  }
3546
3707
  async _addComponent() {
@@ -3803,6 +3964,9 @@
3803
3964
  */
3804
3965
  _applyRootBone() {
3805
3966
  const component = this.component;
3967
+ if (!component) {
3968
+ return;
3969
+ }
3806
3970
  // A non-null root this element did not assign came through the engine API. A fresh
3807
3971
  // component starts at null, which is always reclaimable.
3808
3972
  if (component.rootBone !== null && component.rootBone !== this._managedRootBone) {
@@ -3875,7 +4039,7 @@
3875
4039
  * declared `clip` selection can apply before any asset has loaded.
3876
4040
  */
3877
4041
  _assignClip(clip) {
3878
- this.component.assignAnimation(clip.name, clip._track ?? playcanvas.AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
4042
+ this.component?.assignAnimation(clip.name, clip._track ?? playcanvas.AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
3879
4043
  }
3880
4044
  /**
3881
4045
  * Validates a clip child and, when valid, assigns its state and starts its track resolution.
@@ -4183,8 +4347,9 @@
4183
4347
  component.playing = true;
4184
4348
  }
4185
4349
  /**
4186
- * Gets the underlying PlayCanvas anim component.
4187
- * @returns The anim component.
4350
+ * Gets the underlying PlayCanvas anim component. `null` until the element is
4351
+ * ready see {@link ComponentElement.component}.
4352
+ * @returns The anim component, or `null`.
4188
4353
  */
4189
4354
  get component() {
4190
4355
  return super.component;
@@ -4333,6 +4498,11 @@
4333
4498
  */
4334
4499
  _animElement = null;
4335
4500
  _asset = '';
4501
+ /**
4502
+ * Watches the current source asset while it loads. Starting a new resolution or
4503
+ * disconnecting cancels it, so a superseded source can never hand its track to the parent.
4504
+ */
4505
+ _binding = new AssetBinding();
4336
4506
  /**
4337
4507
  * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
4338
4508
  * a resume from an await abandons itself if the value has moved on, so a stale callback can
@@ -4340,19 +4510,13 @@
4340
4510
  * own callback.
4341
4511
  */
4342
4512
  _connectionGeneration = 0;
4343
- _errorHandle = null;
4344
4513
  /**
4345
4514
  * Incremented on every track resolution and on disconnect, and captured by a resolution when
4346
- * it starts. A resolution that resumes from an await or an asset callback abandons itself if
4347
- * the value has moved on, so a superseded resolution cannot hand a stale track to the parent.
4515
+ * it starts. A resolution that resumes from an await abandons itself if the value has moved
4516
+ * on, so a superseded resolution cannot hand a stale track to the parent. The asset
4517
+ * subscription itself is guarded by the binding above.
4348
4518
  */
4349
4519
  _loadGeneration = 0;
4350
- /**
4351
- * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
4352
- * Held so that whatever supersedes the resolution can detach the handlers from the asset,
4353
- * rather than leave them registered until the asset settles (or forever, if it never does).
4354
- */
4355
- _loadHandle = null;
4356
4520
  _loop = true;
4357
4521
  _name = '';
4358
4522
  _speed = 1;
@@ -4392,7 +4556,7 @@
4392
4556
  // Invalidate any connectedCallback or track resolution still suspended on an await
4393
4557
  this._connectionGeneration++;
4394
4558
  this._loadGeneration++;
4395
- this._detachLoadHandlers();
4559
+ this._binding.cancel();
4396
4560
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
4397
4561
  // by now. The component itself is null if the whole <pc-app> is being torn down —
4398
4562
  // parents disconnect first and have already removed the component.
@@ -4410,12 +4574,6 @@
4410
4574
  }
4411
4575
  return animElement;
4412
4576
  }
4413
- _detachLoadHandlers() {
4414
- this._loadHandle?.off();
4415
- this._loadHandle = null;
4416
- this._errorHandle?.off();
4417
- this._errorHandle = null;
4418
- }
4419
4577
  /**
4420
4578
  * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
4421
4579
  *
@@ -4441,34 +4599,19 @@
4441
4599
  async _resolveTrack(animElement) {
4442
4600
  this._animElement = animElement;
4443
4601
  const generation = ++this._loadGeneration;
4444
- this._detachLoadHandlers();
4602
+ this._binding.cancel();
4445
4603
  if (this._asset) {
4446
- const asset = useAsset(this._asset);
4604
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
4605
+ // delivery below is always current.
4606
+ const asset = this._binding.bind(this._asset, {
4607
+ load: (loaded) => this._extractTrack(loaded, `asset '${this._asset}'`),
4608
+ error: () => {
4609
+ this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4610
+ }
4611
+ });
4447
4612
  if (!asset) {
4448
4613
  this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
4449
- return;
4450
- }
4451
- if (asset.loaded) {
4452
- this._extractTrack(asset, `asset '${this._asset}'`);
4453
- return;
4454
4614
  }
4455
- // Whichever of load/error fires first detaches the other. The generation is
4456
- // re-checked even though a superseded handler is detached: the detach relies on how
4457
- // the engine's event emitter treats removal, while the check holds on its own.
4458
- this._loadHandle = asset.once('load', () => {
4459
- this._detachLoadHandlers();
4460
- if (generation !== this._loadGeneration) {
4461
- return;
4462
- }
4463
- this._extractTrack(asset, `asset '${this._asset}'`);
4464
- });
4465
- this._errorHandle = asset.once('error', () => {
4466
- this._detachLoadHandlers();
4467
- if (generation !== this._loadGeneration) {
4468
- return;
4469
- }
4470
- this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4471
- });
4472
4615
  return;
4473
4616
  }
4474
4617
  const model = animElement.parentElement;
@@ -4660,8 +4803,9 @@
4660
4803
  super('audiolistener');
4661
4804
  }
4662
4805
  /**
4663
- * Gets the underlying PlayCanvas audio listener component.
4664
- * @returns The audio listener component.
4806
+ * Gets the underlying PlayCanvas audio listener component. `null` until the element is
4807
+ * ready see {@link ComponentElement.component}.
4808
+ * @returns The audio listener component, or `null`.
4665
4809
  */
4666
4810
  get component() {
4667
4811
  return super.component;
@@ -4669,6 +4813,227 @@
4669
4813
  }
4670
4814
  customElements.define('pc-audio-listener', AudioListenerComponentElement);
4671
4815
 
4816
+ /**
4817
+ * Resolves entity reference strings against the document. A reference beginning with `#` is a
4818
+ * document-wide selector (an element id, or any selector rooted in one); anything else is an
4819
+ * entity name, resolved lexically through the entity hierarchy first and against the document
4820
+ * after — never as a selector or an id.
4821
+ *
4822
+ * Unlike the attribute parsers in `parse.ts`, these helpers touch the DOM, and `findEntityElement`
4823
+ * and `getEntity` return `null` instead of falling back to a default. They also do not warn -
4824
+ * what an unresolved reference means depends on the element holding it - so elements report
4825
+ * through `resolveEntity`, which takes that meaning as parameters, and `pc-script` builds its own
4826
+ * message from the lower-level pieces.
4827
+ */
4828
+ /**
4829
+ * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
4830
+ * arbitrary author text, so a lookup must fail to `null`, never throw.
4831
+ *
4832
+ * @param selector - The selector to query.
4833
+ * @returns The matched element, or `null`.
4834
+ */
4835
+ const query = (selector) => {
4836
+ try {
4837
+ return document.querySelector(selector);
4838
+ }
4839
+ catch {
4840
+ return null;
4841
+ }
4842
+ };
4843
+ /**
4844
+ * Runs a lookup against one scope, checking the scope element itself before its subtree — a
4845
+ * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
4846
+ * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
4847
+ * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
4848
+ * lookup must fail to `null`, never throw.
4849
+ *
4850
+ * @param scope - The element whose inclusive subtree to search.
4851
+ * @param selector - The selector to query.
4852
+ * @returns The matched element, or `null`.
4853
+ */
4854
+ const queryScope = (scope, selector) => {
4855
+ try {
4856
+ return scope.matches(selector) ? scope : scope.querySelector(selector);
4857
+ }
4858
+ catch {
4859
+ return null;
4860
+ }
4861
+ };
4862
+ /**
4863
+ * Reads the entity a resolved element is backing, through the `entity` accessor every
4864
+ * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
4865
+ *
4866
+ * @param element - The element to read, or `null`.
4867
+ * @returns The backing entity, or `null`.
4868
+ */
4869
+ const entityOf = (element) => {
4870
+ return element?.entity ?? null;
4871
+ };
4872
+ /**
4873
+ * The elements that front an entity: what a bare name can resolve to, and the scopes of the
4874
+ * lexical name lookup.
4875
+ */
4876
+ const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'];
4877
+ /**
4878
+ * The entity-fronting elements as one selector, for the scope walk.
4879
+ */
4880
+ const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
4881
+ /**
4882
+ * Resolves a reference string to the element it names. The grammar is closed — every reference
4883
+ * has exactly one interpretation:
4884
+ *
4885
+ * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
4886
+ * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
4887
+ * runs for it, so an unusually named entity cannot shadow it.
4888
+ * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
4889
+ * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
4890
+ * never interpreted as a selector or an element id, so adding or renaming elements can never
4891
+ * change which form it takes.
4892
+ *
4893
+ * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
4894
+ * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
4895
+ * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
4896
+ * entities by name — every clone resolves within itself before a document-wide lookup could reach
4897
+ * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
4898
+ * scope.
4899
+ *
4900
+ * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
4901
+ * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
4902
+ * something matches but is not backing an entity (yet, or ever).
4903
+ *
4904
+ * @param ref - The reference string to resolve.
4905
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4906
+ * name lookup. Omitted, the name lookup is document-wide only.
4907
+ * @returns The matched element, or `null`.
4908
+ * @internal
4909
+ */
4910
+ const findEntityElement = (ref, from) => {
4911
+ if (!ref) {
4912
+ return null;
4913
+ }
4914
+ // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
4915
+ // '#body' must never shadow the element whose id is 'body'.
4916
+ if (ref.startsWith('#')) {
4917
+ return query(ref);
4918
+ }
4919
+ // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
4920
+ // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
4921
+ const escaped = ref.replace(/["\\]/g, '\\$&');
4922
+ const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
4923
+ if (from) {
4924
+ let scope = from.parentElement?.closest(ENTITY_SCOPES);
4925
+ while (scope) {
4926
+ const element = queryScope(scope, nameSelector);
4927
+ if (element) {
4928
+ return element;
4929
+ }
4930
+ scope = scope.parentElement?.closest(ENTITY_SCOPES);
4931
+ }
4932
+ const app = from.parentElement?.closest('pc-app');
4933
+ if (app) {
4934
+ const element = queryScope(app, nameSelector);
4935
+ if (element) {
4936
+ return element;
4937
+ }
4938
+ }
4939
+ }
4940
+ return query(nameSelector);
4941
+ };
4942
+ /**
4943
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element
4944
+ * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
4945
+ * through the entity hierarchy first when `from` is supplied — or a document-wide `#` selector
4946
+ * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
4947
+ * element (or backing entity) is found.
4948
+ *
4949
+ * @param ref - The reference string to resolve.
4950
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4951
+ * name lookup. Omitted, the name lookup is document-wide only.
4952
+ * @returns The resolved entity, or `null`.
4953
+ * @internal
4954
+ */
4955
+ const getEntity = (ref, from) => {
4956
+ return entityOf(findEntityElement(ref, from));
4957
+ };
4958
+ /**
4959
+ * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
4960
+ * have three different fixes: nothing matches (usually a typo), the matched element is not backing
4961
+ * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
4962
+ * later can work), or the matched element can never back one (the reference points at the wrong
4963
+ * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
4964
+ * element inherits from EntityBaseElement.
4965
+ *
4966
+ * @param element - The element the reference matched, or `null` when nothing did.
4967
+ * @returns The cause, phrased to follow `could not resolve ... -`.
4968
+ * @internal
4969
+ */
4970
+ const unresolvedCause = (element) => {
4971
+ if (!element) {
4972
+ return 'nothing in the document matches it';
4973
+ }
4974
+ const tag = `<${element.tagName.toLowerCase()}>`;
4975
+ return 'entity' in element
4976
+ ? `${tag} matches it but is not backing an entity yet`
4977
+ : `${tag} matches it but cannot back an entity`;
4978
+ };
4979
+ /**
4980
+ * Builds the migration pointer for a bare reference that names nothing but matches the id of an
4981
+ * entity-fronting element - it was almost certainly meant as an id, so point at the form that
4982
+ * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
4983
+ * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
4984
+ * belongs to an element that could never back an entity - suggesting it would only trade this
4985
+ * warning for the wrong-target one.
4986
+ *
4987
+ * @param ref - The unresolved reference.
4988
+ * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
4989
+ * @returns The advice sentence, or an empty string.
4990
+ * @internal
4991
+ */
4992
+ const idHint = (ref, prefix = '') => {
4993
+ const match = !ref.startsWith('#') && document.getElementById(ref);
4994
+ return match && 'entity' in match
4995
+ ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
4996
+ : '';
4997
+ };
4998
+ /**
4999
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
5000
+ * the resolving element ({@link findEntityElement} details the order) and warning when a
5001
+ * non-empty reference does not resolve - otherwise the reference fails silently, invisible
5002
+ * except through the behavior it should have driven. The message names which of the three causes
5003
+ * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
5004
+ *
5005
+ * An empty reference stays silent: it is the unset state of an optional attribute, and on some
5006
+ * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
5007
+ *
5008
+ * @param ref - The reference string to resolve.
5009
+ * @param from - The element resolving the reference; scopes the lookup and names the message.
5010
+ * @param attribute - The attribute being resolved, for the message.
5011
+ * @param consequence - What the unresolved reference means for the element, for the message.
5012
+ * @returns The resolved entity, or `null`.
5013
+ * @internal
5014
+ */
5015
+ const resolveEntity = (ref, from, attribute, consequence) => {
5016
+ if (!ref) {
5017
+ return null;
5018
+ }
5019
+ const element = findEntityElement(ref, from);
5020
+ const entity = entityOf(element);
5021
+ if (!entity) {
5022
+ let advice = `Assign ${attribute} again once the entity exists.`;
5023
+ if (element && !('entity' in element)) {
5024
+ advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
5025
+ }
5026
+ else if (!element) {
5027
+ const hint = idHint(ref);
5028
+ if (hint) {
5029
+ advice = hint;
5030
+ }
5031
+ }
5032
+ console.warn(`${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`);
5033
+ }
5034
+ return entity;
5035
+ };
5036
+
4672
5037
  const transitionModes = new Map([
4673
5038
  ['tint', playcanvas.BUTTON_TRANSITION_MODE_TINT],
4674
5039
  ['sprite', playcanvas.BUTTON_TRANSITION_MODE_SPRITE_CHANGE]
@@ -4722,7 +5087,9 @@
4722
5087
  };
4723
5088
  // The image entity defaults to the button's own entity (which carries the image element)
4724
5089
  // when no explicit reference is provided.
4725
- const imageEntity = this._image ? getEntity(this._image) : this.closestEntity?.entity;
5090
+ const imageEntity = this._image
5091
+ ? resolveEntity(this._image, this, 'image', 'reference ignored')
5092
+ : this.closestEntity?.entity;
4726
5093
  if (imageEntity) {
4727
5094
  data.imageEntity = imageEntity;
4728
5095
  }
@@ -4741,8 +5108,9 @@
4741
5108
  return data;
4742
5109
  }
4743
5110
  /**
4744
- * Gets the underlying PlayCanvas button component.
4745
- * @returns The button component.
5111
+ * Gets the underlying PlayCanvas button component. `null` until the element is
5112
+ * ready see {@link ComponentElement.component}.
5113
+ * @returns The button component, or `null`.
4746
5114
  */
4747
5115
  get component() {
4748
5116
  return super.component;
@@ -4765,21 +5133,27 @@
4765
5133
  return this._active;
4766
5134
  }
4767
5135
  /**
4768
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` whose image
4769
- * element is used for visual transitions. Defaults to the button's own entity — inside a
4770
- * `<pc-model>`, that is the model's host entity, so supply an explicit reference to target a
4771
- * UI entity instead.
5136
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5137
+ * selector) to the entity whose image element is used for visual transitions. An exact name
5138
+ * resolves against the nearest enclosing entity first, then outward, then the document.
5139
+ * Defaults to the button's own entity — inside a `<pc-model>`, that is the model's host
5140
+ * entity, so supply an explicit reference to target a UI entity instead. A non-empty
5141
+ * reference that does not resolve warns and is ignored.
4772
5142
  * @param value - The image entity reference.
4773
5143
  */
4774
5144
  set image(value) {
4775
5145
  this._image = value;
4776
- const entity = getEntity(value);
4777
- if (this.component && entity) {
4778
- this.component.imageEntity = entity;
5146
+ if (this.component) {
5147
+ const entity = resolveEntity(value, this, 'image', 'reference ignored');
5148
+ if (entity) {
5149
+ this.component.imageEntity = entity;
5150
+ }
4779
5151
  }
4780
5152
  }
4781
5153
  /**
4782
- * Gets the reference to the `<pc-entity>` whose image element is used for visual transitions.
5154
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5155
+ * selector) to the entity whose image element is used for visual transitions, or empty for
5156
+ * the button's own entity.
4783
5157
  * @returns The image entity reference.
4784
5158
  */
4785
5159
  get image() {
@@ -5194,8 +5568,9 @@
5194
5568
  }
5195
5569
  }
5196
5570
  /**
5197
- * Gets the underlying PlayCanvas camera component.
5198
- * @returns The camera component.
5571
+ * Gets the underlying PlayCanvas camera component. `null` until the element is
5572
+ * ready see {@link ComponentElement.component}.
5573
+ * @returns The camera component, or `null`.
5199
5574
  */
5200
5575
  get component() {
5201
5576
  return super.component;
@@ -5684,8 +6059,9 @@
5684
6059
  component.renderAsset = asset;
5685
6060
  }
5686
6061
  /**
5687
- * Gets the underlying PlayCanvas collision component.
5688
- * @returns The collision component.
6062
+ * Gets the underlying PlayCanvas collision component. `null` until the element is
6063
+ * ready see {@link ComponentElement.component}.
6064
+ * @returns The collision component, or `null`.
5689
6065
  */
5690
6066
  get component() {
5691
6067
  return super.component;
@@ -5923,8 +6299,9 @@
5923
6299
  return data;
5924
6300
  }
5925
6301
  /**
5926
- * Gets the underlying PlayCanvas element component.
5927
- * @returns The element component.
6302
+ * Gets the underlying PlayCanvas element component. `null` until the element is
6303
+ * ready see {@link ComponentElement.component}.
6304
+ * @returns The element component, or `null`.
5928
6305
  */
5929
6306
  get component() {
5930
6307
  return super.component;
@@ -6507,7 +6884,12 @@
6507
6884
  * primary axis: a hinge rotates about it, a slider translates along it and a ball joint twists
6508
6885
  * about it. The constrained bodies are referenced by `entity-a` and `entity-b`, both of which need
6509
6886
  * a rigid body component; leaving `entity-b` empty constrains `entity-a` to a fixed point in world
6510
- * space. The underlying engine component is in alpha, so its API may change.
6887
+ * space. A reference can name any entity-fronting element `<pc-entity>`, `<pc-model>` or
6888
+ * `<pc-node>`, so a ragdoll can join a model's own skeleton nodes by name — and a name resolves
6889
+ * against the nearest enclosing entity first, then outward through the entity hierarchy, then the
6890
+ * document, while a `#` selector resolves document-wide. A `<template>` prefab with one
6891
+ * entity-fronting root can therefore wire its joints by name and stay self-contained when cloned.
6892
+ * The underlying engine component is in alpha, so its API may change.
6511
6893
  *
6512
6894
  * @elementSummary The `<pc-joint>` element constrains two rigid bodies to each other — a hinged
6513
6895
  * door, a swinging chain, a sliding drawer. Its entity's transform is the joint frame, and
@@ -6661,8 +7043,8 @@
6661
7043
  breakImpulse: this._breakImpulse,
6662
7044
  enableCollision: this._enableCollision,
6663
7045
  enableLimits: this._enableLimits,
6664
- entityA: getEntity(this._entityA),
6665
- entityB: getEntity(this._entityB),
7046
+ entityA: resolveEntity(this._entityA, this, 'entity-a', 'constraint not created'),
7047
+ entityB: resolveEntity(this._entityB, this, 'entity-b', 'constraint not created'),
6666
7048
  limits: this._limits,
6667
7049
  linearDamping: this._linearDamping,
6668
7050
  linearEquilibrium: this._linearEquilibrium,
@@ -6696,8 +7078,9 @@
6696
7078
  component.on('break', this._onBreak, this);
6697
7079
  }
6698
7080
  /**
6699
- * Gets the underlying PlayCanvas joint component.
6700
- * @returns The joint component.
7081
+ * Gets the underlying PlayCanvas joint component. `null` until the element is
7082
+ * ready see {@link ComponentElement.component}.
7083
+ * @returns The joint component, or `null`.
6701
7084
  */
6702
7085
  get component() {
6703
7086
  return super.component;
@@ -6917,39 +7300,48 @@
6917
7300
  return this._enableLimits;
6918
7301
  }
6919
7302
  /**
6920
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6921
- * the first constrained body. The reference resolves when it is set, so an entity created
6922
- * later is picked up by setting the attribute again.
7303
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7304
+ * selector) to the element providing the first constrained body. An exact name resolves
7305
+ * against the nearest enclosing entity first, then outward, then the document. The reference
7306
+ * resolves when it is set, so an entity created later is picked up by setting the attribute
7307
+ * again. A non-empty reference that does not resolve warns, naming which of the two causes it
7308
+ * hit.
6923
7309
  * @param value - The first body's entity reference.
6924
7310
  */
6925
7311
  set entityA(value) {
6926
7312
  this._entityA = value;
6927
7313
  if (this.component) {
6928
- this.component.entityA = getEntity(value);
7314
+ this.component.entityA = resolveEntity(value, this, 'entity-a', 'constraint not created');
6929
7315
  }
6930
7316
  }
6931
7317
  /**
6932
- * Gets the reference to the `<pc-entity>` providing the first constrained body.
7318
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7319
+ * selector) to the element providing the first constrained body.
6933
7320
  * @returns The first body's entity reference.
6934
7321
  */
6935
7322
  get entityA() {
6936
7323
  return this._entityA;
6937
7324
  }
6938
7325
  /**
6939
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6940
- * the second constrained body, or empty to constrain the first body to a fixed point in world
6941
- * space. The reference resolves when it is set, so an entity created later is picked up by
6942
- * setting the attribute again.
7326
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7327
+ * selector) to the element providing the second constrained body, or empty to constrain the
7328
+ * first body to a fixed point in world space. An exact name resolves against the nearest
7329
+ * enclosing entity first, then outward, then the document. The reference resolves when it is
7330
+ * set, so an entity created later is picked up by setting the attribute again. A non-empty
7331
+ * reference that does not resolve warns; an empty one is the documented world-space case and
7332
+ * stays silent.
6943
7333
  * @param value - The second body's entity reference.
6944
7334
  */
6945
7335
  set entityB(value) {
6946
7336
  this._entityB = value;
6947
7337
  if (this.component) {
6948
- this.component.entityB = getEntity(value);
7338
+ this.component.entityB = resolveEntity(value, this, 'entity-b', 'constraint not created');
6949
7339
  }
6950
7340
  }
6951
7341
  /**
6952
- * Gets the reference to the `<pc-entity>` providing the second constrained body.
7342
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7343
+ * selector) to the element providing the second constrained body, or empty for the
7344
+ * world-space case.
6953
7345
  * @returns The second body's entity reference.
6954
7346
  */
6955
7347
  get entityB() {
@@ -7414,8 +7806,9 @@
7414
7806
  };
7415
7807
  }
7416
7808
  /**
7417
- * Gets the underlying PlayCanvas layout child component.
7418
- * @returns The layout child component.
7809
+ * Gets the underlying PlayCanvas layout child component. `null` until the element is
7810
+ * ready see {@link ComponentElement.component}.
7811
+ * @returns The layout child component, or `null`.
7419
7812
  */
7420
7813
  get component() {
7421
7814
  return super.component;
@@ -7634,8 +8027,9 @@
7634
8027
  };
7635
8028
  }
7636
8029
  /**
7637
- * Gets the underlying PlayCanvas layout group component.
7638
- * @returns The layout group component.
8030
+ * Gets the underlying PlayCanvas layout group component. `null` until the element is
8031
+ * ready see {@link ComponentElement.component}.
8032
+ * @returns The layout group component, or `null`.
7639
8033
  */
7640
8034
  get component() {
7641
8035
  return super.component;
@@ -7924,8 +8318,9 @@
7924
8318
  };
7925
8319
  }
7926
8320
  /**
7927
- * Gets the underlying PlayCanvas light component.
7928
- * @returns The light component.
8321
+ * Gets the underlying PlayCanvas light component. `null` until the element is
8322
+ * ready see {@link ComponentElement.component}.
8323
+ * @returns The light component, or `null`.
7929
8324
  */
7930
8325
  get component() {
7931
8326
  return super.component;
@@ -8441,36 +8836,68 @@
8441
8836
  */
8442
8837
  class ParticleSystemComponentElement extends ComponentElement {
8443
8838
  _asset = '';
8839
+ /**
8840
+ * The subscription to the current config asset while its load is in flight. Rebinding
8841
+ * supersedes it and disconnect cancels it, so a superseded config — an earlier asset that
8842
+ * finishes loading after its replacement, or a callback left behind by a previous
8843
+ * connection — can never configure the component.
8844
+ */
8845
+ _binding = new AssetBinding();
8444
8846
  /** @ignore */
8445
8847
  constructor() {
8446
8848
  super('particlesystem');
8447
8849
  }
8448
8850
  getInitialComponentData() {
8449
8851
  const asset = useAsset(this._asset);
8450
- // A lazy config has no resource yet - _loadAsset applies it once the load completes
8852
+ // A lazy config has no resource yet - the config binding applies it once the load
8853
+ // completes
8451
8854
  if (!asset || !asset.resource) {
8452
8855
  return {};
8453
8856
  }
8454
- if (asset.resource.colorMapAsset) {
8455
- const id = asset.resource.colorMapAsset;
8456
- const colorMapAsset = useAsset(id)?.id;
8457
- if (colorMapAsset) {
8458
- asset.resource.colorMapAsset = colorMapAsset;
8459
- }
8460
- }
8857
+ this._resolveColorMap(asset.resource);
8461
8858
  return asset.resource;
8462
8859
  }
8860
+ initComponent() {
8861
+ // A loaded config already arrived through getInitialComponentData - the binding is only
8862
+ // needed for a load still in flight. Resolution here also starts a lazy config's load.
8863
+ const asset = useAsset(this._asset);
8864
+ if (asset && !asset.loaded) {
8865
+ this._bindConfig();
8866
+ }
8867
+ }
8868
+ disconnectedCallback() {
8869
+ // The binding dies with the connection, so a config that finishes loading later cannot
8870
+ // configure the component a reconnection creates - that connection binds afresh.
8871
+ this._binding.cancel();
8872
+ super.disconnectedCallback();
8873
+ }
8463
8874
  /**
8464
- * Gets the underlying PlayCanvas particle system component.
8465
- * @returns The particle system component.
8875
+ * Gets the underlying PlayCanvas particle system component. `null` until the element is
8876
+ * ready see {@link ComponentElement.component}.
8877
+ * @returns The particle system component, or `null`.
8466
8878
  */
8467
8879
  get component() {
8468
8880
  return super.component;
8469
8881
  }
8882
+ /**
8883
+ * Rewrites the config's `colorMapAsset` from the `pc-asset` id it is authored with to the
8884
+ * engine asset id the component resolves, starting the texture's load if it is lazy. The
8885
+ * rewrite is in place, so a config applied again — a host cycle, a reconnection — is already
8886
+ * resolved and passes through unchanged.
8887
+ */
8888
+ _resolveColorMap(resource) {
8889
+ if (resource.colorMapAsset) {
8890
+ const colorMapAsset = useAsset(resource.colorMapAsset)?.id;
8891
+ if (colorMapAsset) {
8892
+ resource.colorMapAsset = colorMapAsset;
8893
+ }
8894
+ }
8895
+ }
8470
8896
  applyConfig(resource) {
8471
8897
  if (!this.component) {
8472
8898
  return;
8473
8899
  }
8900
+ this._resolveColorMap(resource);
8474
8901
  // Set all the config properties on the component
8475
8902
  for (const key in resource) {
8476
8903
  if (Object.hasOwn(resource, key)) {
@@ -8478,20 +8905,10 @@
8478
8905
  }
8479
8906
  }
8480
8907
  }
8481
- async _loadAsset() {
8482
- await this.closestApp?.ready();
8483
- const asset = useAsset(this._asset);
8484
- if (!asset) {
8485
- return;
8486
- }
8487
- if (asset.loaded) {
8488
- this.applyConfig(asset.resource);
8489
- }
8490
- else {
8491
- asset.once('load', () => {
8492
- this.applyConfig(asset.resource);
8493
- });
8494
- }
8908
+ _bindConfig() {
8909
+ this._binding.bind(this._asset, {
8910
+ load: (asset) => this.applyConfig(asset.resource)
8911
+ });
8495
8912
  }
8496
8913
  /**
8497
8914
  * Sets the id of the `pc-asset` to use for the model.
@@ -8500,7 +8917,7 @@
8500
8917
  set asset(value) {
8501
8918
  this._asset = value;
8502
8919
  if (this.isConnected) {
8503
- this._loadAsset();
8920
+ this._bindConfig();
8504
8921
  }
8505
8922
  }
8506
8923
  /**
@@ -8717,11 +9134,12 @@
8717
9134
  _useSkybox = true;
8718
9135
  _useTonemap = true;
8719
9136
  /**
8720
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
8721
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
8722
- * element no longer wants.
9137
+ * One asset binding per texture slot, created on first use and kept for the element's
9138
+ * lifetime. A slot's binding is superseded when the slot is reassigned and cancelled when the
9139
+ * element disconnects, so a late-arriving asset can never write a texture the element no
9140
+ * longer wants.
8723
9141
  */
8724
- _mapHandles = new Map();
9142
+ _mapBindings = new Map();
8725
9143
  _updateScheduled = false;
8726
9144
  _glossConflictWarned = false;
8727
9145
  /**
@@ -8848,10 +9266,9 @@
8848
9266
  material.update();
8849
9267
  }
8850
9268
  disconnectedCallback() {
8851
- for (const handle of this._mapHandles.values()) {
8852
- handle.off();
9269
+ for (const binding of this._mapBindings.values()) {
9270
+ binding.cancel();
8853
9271
  }
8854
- this._mapHandles.clear();
8855
9272
  if (this.material) {
8856
9273
  this.material.destroy();
8857
9274
  this.material = null;
@@ -8897,16 +9314,23 @@
8897
9314
  'the roughness-* attributes invert gloss, so the two families contradict each other. Use one or the other.');
8898
9315
  }
8899
9316
  /**
8900
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
8901
- * has not already. An empty id clears the slot.
9317
+ * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when
9318
+ * it has not already. An empty id clears the slot; a slot keeps its current texture while
9319
+ * the new asset loads, and also across a failed load - a later reload can still deliver.
8902
9320
  *
8903
9321
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
8904
9322
  * @param slot - The material property to write.
8905
9323
  */
8906
9324
  _setMap(id, slot) {
8907
- // Drop any load still pending for this slot - its texture is no longer the one we want
8908
- this._mapHandles.get(slot)?.off();
8909
- this._mapHandles.delete(slot);
9325
+ let binding = this._mapBindings.get(slot);
9326
+ if (!binding) {
9327
+ binding = new AssetBinding();
9328
+ this._mapBindings.set(slot, binding);
9329
+ }
9330
+ // Drop any load still pending for this slot - its texture is no longer the one we want.
9331
+ // Cancelled here rather than left to the bind below, which the material-less and
9332
+ // clear-slot returns never reach.
9333
+ binding.cancel();
8910
9334
  if (!this.material)
8911
9335
  return;
8912
9336
  if (!id) {
@@ -8914,17 +9338,9 @@
8914
9338
  this._scheduleUpdate();
8915
9339
  return;
8916
9340
  }
8917
- const asset = useAsset(id);
8918
- if (!asset)
8919
- return;
8920
- if (asset.loaded) {
8921
- this._applyMap(slot, asset.resource);
8922
- return;
8923
- }
8924
- this._mapHandles.set(slot, asset.once('load', () => {
8925
- this._mapHandles.delete(slot);
8926
- this._applyMap(slot, asset.resource);
8927
- }));
9341
+ binding.bind(id, {
9342
+ load: (asset) => this._applyMap(slot, asset.resource)
9343
+ });
8928
9344
  }
8929
9345
  /**
8930
9346
  * @param slot - The material property to write.
@@ -10820,8 +11236,9 @@
10820
11236
  };
10821
11237
  }
10822
11238
  /**
10823
- * Gets the underlying PlayCanvas render component.
10824
- * @returns The render component.
11239
+ * Gets the underlying PlayCanvas render component. `null` until the element is
11240
+ * ready see {@link ComponentElement.component}.
11241
+ * @returns The render component, or `null`.
10825
11242
  */
10826
11243
  get component() {
10827
11244
  return super.component;
@@ -10992,8 +11409,9 @@
10992
11409
  };
10993
11410
  }
10994
11411
  /**
10995
- * Gets the underlying PlayCanvas rigidbody component.
10996
- * @returns The rigidbody component.
11412
+ * Gets the underlying PlayCanvas rigidbody component. `null` until the element is
11413
+ * ready see {@link ComponentElement.component}.
11414
+ * @returns The rigidbody component, or `null`.
10997
11415
  */
10998
11416
  get component() {
10999
11417
  return super.component;
@@ -11172,8 +11590,9 @@
11172
11590
  };
11173
11591
  }
11174
11592
  /**
11175
- * Gets the underlying PlayCanvas screen component.
11176
- * @returns The screen component.
11593
+ * Gets the underlying PlayCanvas screen component. `null` until the element is
11594
+ * ready see {@link ComponentElement.component}.
11595
+ * @returns The screen component, or `null`.
11177
11596
  */
11178
11597
  get component() {
11179
11598
  return super.component;
@@ -11323,15 +11742,16 @@
11323
11742
  value: this._value,
11324
11743
  handleSize: this._handleSize
11325
11744
  };
11326
- const handle = getEntity(this._handle);
11745
+ const handle = resolveEntity(this._handle, this, 'handle', 'reference ignored');
11327
11746
  if (handle) {
11328
11747
  data.handleEntity = handle;
11329
11748
  }
11330
11749
  return data;
11331
11750
  }
11332
11751
  /**
11333
- * Gets the underlying PlayCanvas scrollbar component.
11334
- * @returns The scrollbar component.
11752
+ * Gets the underlying PlayCanvas scrollbar component. `null` until the element is
11753
+ * ready see {@link ComponentElement.component}.
11754
+ * @returns The scrollbar component, or `null`.
11335
11755
  */
11336
11756
  get component() {
11337
11757
  return super.component;
@@ -11389,19 +11809,24 @@
11389
11809
  return this._handleSize;
11390
11810
  }
11391
11811
  /**
11392
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11393
- * scrollbar handle.
11812
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11813
+ * selector) to the entity used as the scrollbar handle. An exact name resolves against the
11814
+ * nearest enclosing entity first, then outward, then the document. A non-empty reference that
11815
+ * does not resolve warns and is ignored.
11394
11816
  * @param value - The handle entity reference.
11395
11817
  */
11396
11818
  set handle(value) {
11397
11819
  this._handle = value;
11398
- const entity = getEntity(value);
11399
- if (this.component && entity) {
11400
- this.component.handleEntity = entity;
11820
+ if (this.component) {
11821
+ const entity = resolveEntity(value, this, 'handle', 'reference ignored');
11822
+ if (entity) {
11823
+ this.component.handleEntity = entity;
11824
+ }
11401
11825
  }
11402
11826
  }
11403
11827
  /**
11404
- * Gets the reference to the `<pc-entity>` used as the scrollbar handle.
11828
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11829
+ * selector) to the entity used as the scrollbar handle.
11405
11830
  * @returns The handle entity reference.
11406
11831
  */
11407
11832
  get handle() {
@@ -11483,27 +11908,28 @@
11483
11908
  horizontalScrollbarVisibility: visibilities.get(this._horizontalScrollbarVisibility),
11484
11909
  verticalScrollbarVisibility: visibilities.get(this._verticalScrollbarVisibility)
11485
11910
  };
11486
- const viewport = getEntity(this._viewport);
11911
+ const viewport = resolveEntity(this._viewport, this, 'viewport', 'reference ignored');
11487
11912
  if (viewport) {
11488
11913
  data.viewportEntity = viewport;
11489
11914
  }
11490
- const content = getEntity(this._content);
11915
+ const content = resolveEntity(this._content, this, 'content', 'reference ignored');
11491
11916
  if (content) {
11492
11917
  data.contentEntity = content;
11493
11918
  }
11494
- const horizontalScrollbar = getEntity(this._horizontalScrollbar);
11919
+ const horizontalScrollbar = resolveEntity(this._horizontalScrollbar, this, 'horizontal-scrollbar', 'reference ignored');
11495
11920
  if (horizontalScrollbar) {
11496
11921
  data.horizontalScrollbarEntity = horizontalScrollbar;
11497
11922
  }
11498
- const verticalScrollbar = getEntity(this._verticalScrollbar);
11923
+ const verticalScrollbar = resolveEntity(this._verticalScrollbar, this, 'vertical-scrollbar', 'reference ignored');
11499
11924
  if (verticalScrollbar) {
11500
11925
  data.verticalScrollbarEntity = verticalScrollbar;
11501
11926
  }
11502
11927
  return data;
11503
11928
  }
11504
11929
  /**
11505
- * Gets the underlying PlayCanvas scroll view component.
11506
- * @returns The scroll view component.
11930
+ * Gets the underlying PlayCanvas scroll view component. `null` until the element is
11931
+ * ready see {@link ComponentElement.component}.
11932
+ * @returns The scroll view component, or `null`.
11507
11933
  */
11508
11934
  get component() {
11509
11935
  return super.component;
@@ -11671,76 +12097,96 @@
11671
12097
  return this._verticalScrollbarVisibility;
11672
12098
  }
11673
12099
  /**
11674
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11675
- * viewport, which clips the content to the scroll view's bounds.
12100
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12101
+ * selector) to the entity used as the viewport, which clips the content to the scroll view's
12102
+ * bounds. An exact name resolves against the nearest enclosing entity first, then outward,
12103
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11676
12104
  * @param value - The viewport entity reference.
11677
12105
  */
11678
12106
  set viewport(value) {
11679
12107
  this._viewport = value;
11680
- const entity = getEntity(value);
11681
- if (this.component && entity) {
11682
- this.component.viewportEntity = entity;
12108
+ if (this.component) {
12109
+ const entity = resolveEntity(value, this, 'viewport', 'reference ignored');
12110
+ if (entity) {
12111
+ this.component.viewportEntity = entity;
12112
+ }
11683
12113
  }
11684
12114
  }
11685
12115
  /**
11686
- * Gets the reference to the `<pc-entity>` used as the viewport.
12116
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12117
+ * selector) to the entity used as the viewport.
11687
12118
  * @returns The viewport entity reference.
11688
12119
  */
11689
12120
  get viewport() {
11690
12121
  return this._viewport;
11691
12122
  }
11692
12123
  /**
11693
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11694
- * content, which is moved as the scroll view is scrolled.
12124
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12125
+ * selector) to the entity used as the content, which is moved as the scroll view is
12126
+ * scrolled. An exact name resolves against the nearest enclosing entity first, then outward,
12127
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11695
12128
  * @param value - The content entity reference.
11696
12129
  */
11697
12130
  set content(value) {
11698
12131
  this._content = value;
11699
- const entity = getEntity(value);
11700
- if (this.component && entity) {
11701
- this.component.contentEntity = entity;
12132
+ if (this.component) {
12133
+ const entity = resolveEntity(value, this, 'content', 'reference ignored');
12134
+ if (entity) {
12135
+ this.component.contentEntity = entity;
12136
+ }
11702
12137
  }
11703
12138
  }
11704
12139
  /**
11705
- * Gets the reference to the `<pc-entity>` used as the content.
12140
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12141
+ * selector) to the entity used as the content.
11706
12142
  * @returns The content entity reference.
11707
12143
  */
11708
12144
  get content() {
11709
12145
  return this._content;
11710
12146
  }
11711
12147
  /**
11712
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11713
- * the horizontal `<pc-scrollbar>`.
12148
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12149
+ * selector) to the entity containing the horizontal `<pc-scrollbar>`. An exact name resolves
12150
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12151
+ * reference that does not resolve warns and is ignored.
11714
12152
  * @param value - The horizontal scrollbar entity reference.
11715
12153
  */
11716
12154
  set horizontalScrollbar(value) {
11717
12155
  this._horizontalScrollbar = value;
11718
- const entity = getEntity(value);
11719
- if (this.component && entity) {
11720
- this.component.horizontalScrollbarEntity = entity;
12156
+ if (this.component) {
12157
+ const entity = resolveEntity(value, this, 'horizontal-scrollbar', 'reference ignored');
12158
+ if (entity) {
12159
+ this.component.horizontalScrollbarEntity = entity;
12160
+ }
11721
12161
  }
11722
12162
  }
11723
12163
  /**
11724
- * Gets the reference to the `<pc-entity>` containing the horizontal scrollbar.
12164
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12165
+ * selector) to the entity containing the horizontal scrollbar.
11725
12166
  * @returns The horizontal scrollbar entity reference.
11726
12167
  */
11727
12168
  get horizontalScrollbar() {
11728
12169
  return this._horizontalScrollbar;
11729
12170
  }
11730
12171
  /**
11731
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11732
- * the vertical `<pc-scrollbar>`.
12172
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12173
+ * selector) to the entity containing the vertical `<pc-scrollbar>`. An exact name resolves
12174
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12175
+ * reference that does not resolve warns and is ignored.
11733
12176
  * @param value - The vertical scrollbar entity reference.
11734
12177
  */
11735
12178
  set verticalScrollbar(value) {
11736
12179
  this._verticalScrollbar = value;
11737
- const entity = getEntity(value);
11738
- if (this.component && entity) {
11739
- this.component.verticalScrollbarEntity = entity;
12180
+ if (this.component) {
12181
+ const entity = resolveEntity(value, this, 'vertical-scrollbar', 'reference ignored');
12182
+ if (entity) {
12183
+ this.component.verticalScrollbarEntity = entity;
12184
+ }
11740
12185
  }
11741
12186
  }
11742
12187
  /**
11743
- * Gets the reference to the `<pc-entity>` containing the vertical scrollbar.
12188
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12189
+ * selector) to the entity containing the vertical scrollbar.
11744
12190
  * @returns The vertical scrollbar entity reference.
11745
12191
  */
11746
12192
  get verticalScrollbar() {
@@ -11823,7 +12269,9 @@
11823
12269
  * Values are parsed according to the type of the attribute's current value — initially the
11824
12270
  * script's declared default (numbers, booleans, strings, Vec2/3/4, Color, Quat as Euler
11825
12271
  * angles) — and the `asset:`/`entity:`/`vec2:`/`vec3:`/`vec4:`/`color:` prefixes may be used
11826
- * to be explicit.
12272
+ * to be explicit. An `entity:` reference is an entity name — resolved against the nearest
12273
+ * enclosing entity first, then outward, then the document — or a document-wide `#` selector
12274
+ * (`entity:#id`); a bare value is always a name, never an element id.
11827
12275
  * - **The `attributes` JSON attribute**: an object supporting nested structures and attribute
11828
12276
  * names that collide with reserved HTML attribute names (e.g. `title`).
11829
12277
  *
@@ -11839,7 +12287,8 @@
11839
12287
  *
11840
12288
  * @elementSummary The `<pc-script-instance>` element attaches one script class, named by `name`, to
11841
12289
  * the entity of its parent `<pc-script>`. Its other attributes set script attributes of the same
11842
- * name, and `attributes` takes a JSON object instead. Must be a direct child of `<pc-script>`.
12290
+ * name, and `attributes` takes a JSON object instead. An `entity:` value is an entity name —
12291
+ * write `entity:#id` for an element id. Must be a direct child of `<pc-script>`.
11843
12292
  *
11844
12293
  * @fires {CustomEvent} scriptattributeschange - Fired when the script's attributes change. The
11845
12294
  * `detail` carries the new `attributes` object. Bubbles.
@@ -11859,9 +12308,11 @@
11859
12308
  /**
11860
12309
  * Sets the attributes of the script as an object. Values are converted with the same rules
11861
12310
  * as the `attributes` attribute: `asset:`/`entity:` references and `vec2:`/`vec3:`/`vec4:`/
11862
- * `color:` prefixed strings are resolved, and a plain numeric array is converted to the
11863
- * type of the attribute it targets when that attribute currently holds a Vec2, Vec3, Vec4
11864
- * or Color.
12311
+ * `color:` prefixed strings are resolved (an entity name against the nearest enclosing
12312
+ * entity first, then outward, then the document or a document-wide `#` selector; a bare
12313
+ * value is always a name, never an element id), and a plain numeric array is converted to
12314
+ * the type of the attribute it targets when that attribute currently holds a Vec2, Vec3,
12315
+ * Vec4 or Color.
11865
12316
  * @param value - The attributes of the script.
11866
12317
  */
11867
12318
  set scriptAttributes(value) {
@@ -11872,7 +12323,10 @@
11872
12323
  }));
11873
12324
  }
11874
12325
  /**
11875
- * Gets the attributes of the script.
12326
+ * Gets the attributes of the script as an object whose `asset:`, `entity:`, `vec2:`, `vec3:`,
12327
+ * `vec4:` and `color:` prefixed values are resolved when applied — an `entity:` value being
12328
+ * an entity name (nearest enclosing entity first, then outward, then the document) or a
12329
+ * document-wide `#` selector (`entity:#id`), never a bare element id.
11876
12330
  * @returns The attributes of the script.
11877
12331
  */
11878
12332
  get scriptAttributes() {
@@ -12083,18 +12537,23 @@
12083
12537
  return raw;
12084
12538
  };
12085
12539
  /**
12086
- * Resolves an `entity:` prefix to the Entity backing a `pc-entity` element. The reference can be a
12087
- * CSS selector, an element id or an entity name.
12540
+ * Resolves an `entity:` prefix to the Entity backing a `pc-entity`, `pc-model` or `pc-node`
12541
+ * element. The reference is a name resolved against the nearest enclosing entity first, then
12542
+ * outward, then the document — or a document-wide `#` selector. The failure warning names which
12543
+ * of the three causes ({@link unresolvedCause}) it hit.
12088
12544
  * @param rest - The entity reference.
12089
12545
  * @param raw - The raw value, returned unchanged when the reference does not resolve.
12546
+ * @param from - The element the value is declared under, which scopes the reference.
12090
12547
  * @returns The entity, or `raw`.
12091
12548
  */
12092
- const entityConversion = (rest, raw) => {
12093
- const entity = getEntity(rest);
12549
+ const entityConversion = (rest, raw, from) => {
12550
+ const entity = getEntity(rest, from);
12094
12551
  if (entity) {
12095
12552
  return entity;
12096
12553
  }
12097
- console.warn(`Unable to resolve '${raw}' in script attributes - no pc-entity found matching '${rest}'.`);
12554
+ const element = findEntityElement(rest, from);
12555
+ const hint = element ? '' : idHint(rest, 'entity:');
12556
+ console.warn(`Unable to resolve '${raw}' in script attributes - ${unresolvedCause(element)}.${hint ? ` ${hint}` : ''}`);
12098
12557
  return raw;
12099
12558
  };
12100
12559
  /**
@@ -12227,8 +12686,10 @@
12227
12686
  /**
12228
12687
  * Recursively converts raw attribute data into proper PlayCanvas types. Supported conversions:
12229
12688
  * - "asset:id" → the Asset created by the `pc-asset` element with that id
12230
- * - "entity:ref" → the Entity backing a `pc-entity` element. The reference can be a CSS
12231
- * selector, an element id or an entity name.
12689
+ * - "entity:ref" → the Entity backing a `pc-entity`, `pc-model` or `pc-node` element. The
12690
+ * reference is a name, resolved against this element's nearest enclosing entity first,
12691
+ * then outward, then the document — or a document-wide `#` selector (`entity:#id`). A bare
12692
+ * value is always a name, never an id.
12232
12693
  * - "vec2:1 2" → new Vec2(1, 2)
12233
12694
  * - "vec3:1 2 3" → new Vec3(1, 2, 3)
12234
12695
  * - "vec4:1 2 3 4" → new Vec4(1, 2, 3, 4)
@@ -12242,7 +12703,7 @@
12242
12703
  convertAttributes(item) {
12243
12704
  if (typeof item === 'string') {
12244
12705
  const match = matchConversion(item);
12245
- return match ? match.convert(match.rest, item) : item;
12706
+ return match ? match.convert(match.rest, item, this) : item;
12246
12707
  }
12247
12708
  if (Array.isArray(item)) {
12248
12709
  return item.map((element) => this.convertAttributes(element));
@@ -12620,8 +13081,9 @@
12620
13081
  super.disconnectedCallback?.();
12621
13082
  }
12622
13083
  /**
12623
- * Gets the underlying PlayCanvas script component.
12624
- * @returns The script component.
13084
+ * Gets the underlying PlayCanvas script component. `null` until the element is
13085
+ * ready see {@link ComponentElement.component}.
13086
+ * @returns The script component, or `null`.
12625
13087
  */
12626
13088
  get component() {
12627
13089
  return super.component;
@@ -12667,8 +13129,9 @@
12667
13129
  };
12668
13130
  }
12669
13131
  /**
12670
- * Gets the underlying PlayCanvas sound component.
12671
- * @returns The sound component.
13132
+ * Gets the underlying PlayCanvas sound component. `null` until the element is
13133
+ * ready see {@link ComponentElement.component}.
13134
+ * @returns The sound component, or `null`.
12672
13135
  */
12673
13136
  get component() {
12674
13137
  return super.component;
@@ -13149,8 +13612,9 @@
13149
13612
  };
13150
13613
  }
13151
13614
  /**
13152
- * Gets the underlying PlayCanvas gsplat component.
13153
- * @returns The gsplat component.
13615
+ * Gets the underlying PlayCanvas gsplat component. `null` until the element is
13616
+ * ready see {@link ComponentElement.component}.
13617
+ * @returns The gsplat component, or `null`.
13154
13618
  */
13155
13619
  get component() {
13156
13620
  return super.component;
@@ -14443,31 +14907,27 @@
14443
14907
  _appElement = null;
14444
14908
  /**
14445
14909
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
14446
- * load that resumes from an await or a load callback abandons itself if the value has moved
14447
- * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
14910
+ * load that resumes from an await abandons itself if the value has moved on, so a superseded
14911
+ * load cannot generate a skybox for a scene it no longer configures. The asset subscription
14912
+ * itself is guarded by the binding below.
14448
14913
  */
14449
14914
  _loadGeneration = 0;
14450
14915
  /**
14451
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
14452
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
14453
- * than leave it registered until the asset loads (or forever, if it never does).
14916
+ * Watches the current texture asset while it loads. Starting a new load or disconnecting
14917
+ * cancels it, so a superseded texture can never generate the skybox.
14454
14918
  */
14455
- _loadHandle = null;
14919
+ _binding = new AssetBinding();
14456
14920
  connectedCallback() {
14457
14921
  this._loadSkybox();
14458
14922
  this._onReady();
14459
14923
  }
14460
14924
  disconnectedCallback() {
14461
14925
  this._loadGeneration++;
14462
- this._detachLoadHandler();
14926
+ this._binding.cancel();
14463
14927
  this._unloadSkybox();
14464
14928
  this._appElement = null;
14465
14929
  this._resetReady();
14466
14930
  }
14467
- _detachLoadHandler() {
14468
- this._loadHandle?.off();
14469
- this._loadHandle = null;
14470
- }
14471
14931
  _generateSkybox(asset) {
14472
14932
  if (!this._scene)
14473
14933
  return;
@@ -14499,7 +14959,7 @@
14499
14959
  async _loadSkybox() {
14500
14960
  // Supersede any load already in flight - only the newest load may generate the skybox
14501
14961
  const generation = ++this._loadGeneration;
14502
- this._detachLoadHandler();
14962
+ this._binding.cancel();
14503
14963
  const appElement = await this.closestApp?.ready();
14504
14964
  // The element may have been removed, or another load started, while we waited
14505
14965
  if (generation !== this._loadGeneration) {
@@ -14510,26 +14970,18 @@
14510
14970
  return;
14511
14971
  }
14512
14972
  this._appElement = appElement;
14513
- const asset = useAsset(this._asset);
14514
- if (!asset) {
14973
+ // The scene is only adopted once the reference resolves: an unresolved id must leave the
14974
+ // scene untouched, or this element's teardown would destroy a skybox it never created.
14975
+ // The bind below repeats the resolution, which useAsset documents as free - it cannot
14976
+ // happen after the bind, because a loaded asset delivers before bind returns and
14977
+ // _generateSkybox needs the scene by then.
14978
+ if (!useAsset(this._asset)) {
14515
14979
  return;
14516
14980
  }
14517
14981
  this._scene = app.scene;
14518
- if (asset.loaded) {
14519
- this._generateSkybox(asset);
14520
- }
14521
- else {
14522
- // The generation is re-checked even though a superseded handler is detached: the
14523
- // detach relies on how the engine's event emitter treats removal, while the check
14524
- // holds on its own.
14525
- this._loadHandle = asset.once('load', () => {
14526
- this._loadHandle = null;
14527
- if (generation !== this._loadGeneration) {
14528
- return;
14529
- }
14530
- this._generateSkybox(asset);
14531
- });
14532
- }
14982
+ this._binding.bind(this._asset, {
14983
+ load: (asset) => this._generateSkybox(asset)
14984
+ });
14533
14985
  }
14534
14986
  _unloadSkybox() {
14535
14987
  const scene = this._scene;