@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.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { basisInitialize, WasmModule, Vec3, Color, Vec4, Quat, Vec2, createGraphicsDevice, AppOptions, Keyboard, Mouse, ElementInput, AnimComponentSystem, AnimationComponentSystem, AudioListenerComponentSystem, ButtonComponentSystem, CameraComponentSystem, CollisionComponentSystem, ElementComponentSystem, GSplatComponentSystem, JointComponentSystem, LayoutChildComponentSystem, LayoutGroupComponentSystem, LightComponentSystem, ModelComponentSystem, ParticleSystemComponentSystem, RenderComponentSystem, RigidBodyComponentSystem, ScreenComponentSystem, ScriptComponentSystem, ScrollbarComponentSystem, ScrollViewComponentSystem, SoundComponentSystem, SpriteComponentSystem, ZoneComponentSystem, AnimClipHandler, AnimationHandler, AnimStateGraphHandler, AudioHandler, BinaryHandler, CssHandler, ContainerHandler, CubemapHandler, FolderHandler, FontHandler, GSplatHandler, HierarchyHandler, HtmlHandler, JsonHandler, MaterialHandler, ModelHandler, RenderHandler, ScriptHandler, SceneHandler, ShaderHandler, SpriteHandler, TemplateHandler, TextHandler, TextureAtlasHandler, TextureHandler, SoundManager, Lightmapper, BatchManager, XrManager, AppBase, FILLMODE_NONE, RESOLUTION_AUTO, Picker, MeshInstance, Entity, Asset, SPRITE_RENDERMODE_SIMPLE, FILTER_LINEAR_MIPMAP_LINEAR, FILTER_LINEAR, ADDRESS_REPEAT, SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED, ADDRESS_CLAMP_TO_EDGE, ADDRESS_MIRRORED_REPEAT, FILTER_NEAREST, FILTER_NEAREST_MIPMAP_NEAREST, FILTER_LINEAR_MIPMAP_NEAREST, FILTER_NEAREST_MIPMAP_LINEAR, AnimTrack, ANIM_CONTROL_STATES, BUTTON_TRANSITION_MODE_TINT, BUTTON_TRANSITION_MODE_SPRITE_CHANGE, TONEMAP_NONE, PROJECTION_PERSPECTIVE, GAMMA_SRGB, GAMMA_NONE, XRTYPE_AR, XRTYPE_VR, TONEMAP_LINEAR, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_NEUTRAL, PROJECTION_ORTHOGRAPHIC, ORIENTATION_HORIZONTAL, FITTING_NONE, FITTING_STRETCH, FITTING_SHRINK, FITTING_BOTH, ORIENTATION_VERTICAL, SHADOW_PCF3_32F, SHADOW_PCF1_16F, SHADOW_PCF1_32F, SHADOW_PCF3_16F, SHADOW_PCF5_16F, SHADOW_PCF5_32F, SHADOW_VSM_16F, SHADOW_VSM_32F, SHADOW_PCSS_32F, StandardMaterial, BLEND_NONE, CULLFACE_BACK, FRESNEL_SCHLICK, SPECOCC_AO, BLEND_NORMAL, BLEND_ADDITIVE, BLEND_ADDITIVEALPHA, BLEND_PREMULTIPLIED, BLEND_MULTIPLICATIVE, BLEND_MULTIPLICATIVE2X, BLEND_SCREEN, BLEND_MIN, BLEND_MAX, BLEND_SUBTRACTIVE, CULLFACE_NONE, CULLFACE_FRONT, CULLFACE_FRONTANDBACK, FRESNEL_NONE, SPECOCC_NONE, SPECOCC_GLOSSDEPENDENT, SCALEMODE_NONE, SCALEMODE_BLEND, SCROLL_MODE_BOUNCE, SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED, SCROLLBAR_VISIBILITY_SHOW_ALWAYS, SCROLL_MODE_CLAMP, SCROLL_MODE_INFINITE, EnvLighting, LAYERID_SKYBOX } from 'playcanvas';
1
+ import { basisInitialize, WasmModule, Vec3, Color, Vec4, Quat, Vec2, Picker, MeshInstance, createGraphicsDevice, AppOptions, Keyboard, Mouse, ElementInput, AnimComponentSystem, AnimationComponentSystem, AudioListenerComponentSystem, ButtonComponentSystem, CameraComponentSystem, CollisionComponentSystem, ElementComponentSystem, GSplatComponentSystem, JointComponentSystem, LayoutChildComponentSystem, LayoutGroupComponentSystem, LightComponentSystem, ModelComponentSystem, ParticleSystemComponentSystem, RenderComponentSystem, RigidBodyComponentSystem, ScreenComponentSystem, ScriptComponentSystem, ScrollbarComponentSystem, ScrollViewComponentSystem, SoundComponentSystem, SpriteComponentSystem, ZoneComponentSystem, AnimClipHandler, AnimationHandler, AnimStateGraphHandler, AudioHandler, BinaryHandler, CssHandler, ContainerHandler, CubemapHandler, FolderHandler, FontHandler, GSplatHandler, HierarchyHandler, HtmlHandler, JsonHandler, MaterialHandler, ModelHandler, RenderHandler, ScriptHandler, SceneHandler, ShaderHandler, SpriteHandler, TemplateHandler, TextHandler, TextureAtlasHandler, TextureHandler, SoundManager, Lightmapper, BatchManager, XrManager, AppBase, FILLMODE_NONE, RESOLUTION_AUTO, Entity, Asset, SPRITE_RENDERMODE_SIMPLE, FILTER_LINEAR_MIPMAP_LINEAR, FILTER_LINEAR, ADDRESS_REPEAT, SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED, ADDRESS_CLAMP_TO_EDGE, ADDRESS_MIRRORED_REPEAT, FILTER_NEAREST, FILTER_NEAREST_MIPMAP_NEAREST, FILTER_LINEAR_MIPMAP_NEAREST, FILTER_NEAREST_MIPMAP_LINEAR, AnimTrack, ANIM_CONTROL_STATES, BUTTON_TRANSITION_MODE_TINT, BUTTON_TRANSITION_MODE_SPRITE_CHANGE, TONEMAP_NONE, PROJECTION_PERSPECTIVE, GAMMA_SRGB, GAMMA_NONE, XRTYPE_AR, XRTYPE_VR, TONEMAP_LINEAR, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_NEUTRAL, PROJECTION_ORTHOGRAPHIC, ORIENTATION_HORIZONTAL, FITTING_NONE, FITTING_STRETCH, FITTING_SHRINK, FITTING_BOTH, ORIENTATION_VERTICAL, SHADOW_PCF3_32F, SHADOW_PCF1_16F, SHADOW_PCF1_32F, SHADOW_PCF3_16F, SHADOW_PCF5_16F, SHADOW_PCF5_32F, SHADOW_VSM_16F, SHADOW_VSM_32F, SHADOW_PCSS_32F, StandardMaterial, BLEND_NONE, CULLFACE_BACK, FRESNEL_SCHLICK, SPECOCC_AO, BLEND_NORMAL, BLEND_ADDITIVE, BLEND_ADDITIVEALPHA, BLEND_PREMULTIPLIED, BLEND_MULTIPLICATIVE, BLEND_MULTIPLICATIVE2X, BLEND_SCREEN, BLEND_MIN, BLEND_MAX, BLEND_SUBTRACTIVE, CULLFACE_NONE, CULLFACE_FRONT, CULLFACE_FRONTANDBACK, FRESNEL_NONE, SPECOCC_NONE, SPECOCC_GLOSSDEPENDENT, SCALEMODE_NONE, SCALEMODE_BLEND, SCROLL_MODE_BOUNCE, SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED, SCROLLBAR_VISIBILITY_SHOW_ALWAYS, SCROLL_MODE_CLAMP, SCROLL_MODE_INFINITE, EnvLighting, LAYERID_SKYBOX } from 'playcanvas';
2
2
 
3
3
  /**
4
4
  * Base class for all PlayCanvas Web Components that initialize asynchronously.
@@ -584,9 +584,6 @@ const CSS_COLORS = {
584
584
  * shared frozen constants (`Vec3.ZERO`, `Color.WHITE`) as defaults.
585
585
  * - `parseBool` and `parseTags` take no attribute name, because every value is valid for them and
586
586
  * so they never warn.
587
- *
588
- * `getEntity` is the exception: it resolves a reference to a live entity rather than parsing a
589
- * literal, and returns `null` instead of falling back to a default.
590
587
  */
591
588
  /**
592
589
  * Splits an attribute value into exactly `count` numeric components. Returns `null` when the
@@ -832,34 +829,10 @@ const parseVec4 = (value, defaultValue, attribute) => {
832
829
  }
833
830
  return new Vec4(components);
834
831
  };
835
- /**
836
- * Resolves a reference string to the {@link Entity} backing a `<pc-entity>` element. The reference
837
- * can be a CSS selector (e.g. `#my-id`, `pc-entity[name="Foo"]`), a bare element id, or a bare
838
- * entity name. Returns `null` if no matching element (or backing entity) is found.
839
- *
840
- * @param ref - The reference string to resolve.
841
- * @returns The resolved entity, or `null`.
842
- * @internal
843
- */
844
- const getEntity = (ref) => {
845
- if (!ref) {
846
- return null;
847
- }
848
- let element = null;
849
- // Try the reference as a CSS selector. An invalid selector (e.g. a bare name containing
850
- // spaces) throws, in which case we fall back to id/name lookups below.
851
- try {
852
- element = document.querySelector(ref);
853
- }
854
- catch {
855
- element = null;
856
- }
857
- if (!element) {
858
- element = document.getElementById(ref) ?? document.querySelector(`pc-entity[name="${ref}"]`);
859
- }
860
- return element?.entity ?? null;
861
- };
862
832
 
833
+ // Keep `export` on these declarations. TypeScript removes the declaration and its inline export
834
+ // when `stripInternal` is enabled. A separate `export { ... }` statement would remain in the
835
+ // generated .d.ts file and refer to a declaration that had been removed.
863
836
  /**
864
837
  * The event types whose listeners make an element a hover target. Hover resolution walks past
865
838
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -903,8 +876,428 @@ const commonAncestor = (a, b) => {
903
876
  return node;
904
877
  }
905
878
  }
906
- return null;
907
- };
879
+ return null;
880
+ };
881
+ /**
882
+ * The pointer-input subsystem of a `<pc-app>` element: it owns the engine {@link Picker}, the
883
+ * canvas pointer handlers, and everything between them - mapping browser coordinates into the
884
+ * drawing buffer, selecting the camera, resolving picked nodes to listening elements, tracking
885
+ * hover, and dispatching the synthesized pointer and click events in canvas-event order.
886
+ *
887
+ * The host drives a small lifecycle: {@link connect} once the application and canvas exist,
888
+ * {@link resize} when the drawing buffer changes size, {@link syncListeners} when a descendant's
889
+ * pointer listeners change, and {@link disconnect} on teardown. Everything else is internal.
890
+ *
891
+ * @internal
892
+ */
893
+ class PointerController {
894
+ _host;
895
+ /**
896
+ * Incremented by every connect and disconnect. Async work captures the value when it starts
897
+ * and stops if it has moved on - so a pick or dispatch belonging to an earlier connection
898
+ * can neither keep reading through its destroyed picker nor deliver into a later
899
+ * connection. The field null checks alone cannot tell the two apart once a reconnect has
900
+ * repopulated them.
901
+ */
902
+ _generation = 0;
903
+ _app = null;
904
+ _canvas = null;
905
+ _picker = null;
906
+ _hoveredEntity = null;
907
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
908
+ _pickToken = 0;
909
+ _pointerHandlers = {
910
+ pointermove: null,
911
+ pointerdown: null,
912
+ pointerup: null,
913
+ pointercancel: null
914
+ };
915
+ /**
916
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
917
+ * may still conclude it. The promise is stored rather than its result, so a release can
918
+ * await a press pick that has not resolved yet. Entries are removed by the matching
919
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
920
+ * which is also what keeps those two canvas listeners attached.
921
+ */
922
+ _downPicks = new Map();
923
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
924
+ _clickListened = false;
925
+ /**
926
+ * The previous click's target, time and count, for chaining successive clicks into the
927
+ * click count that `detail` carries. `null` until a click has fired.
928
+ */
929
+ _lastClick = null;
930
+ /**
931
+ * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
932
+ * whose picks resolve in GPU order, not canvas-event order. Replaced on disconnect, so a
933
+ * pick that never resolves cannot stall the dispatches of a later boot.
934
+ */
935
+ _dispatchChain = Promise.resolve();
936
+ /**
937
+ * @param host - The services the controller reads from its host element.
938
+ */
939
+ constructor(host) {
940
+ this._host = host;
941
+ }
942
+ /**
943
+ * Creates the picker and the canvas handlers for a booted application, and attaches whatever
944
+ * canvas listeners the tree's current element listeners already need (handlers created from
945
+ * inline attributes when their elements were first upgraded, or listeners carried over from
946
+ * before a re-boot).
947
+ *
948
+ * @param app - The application to pick against.
949
+ * @param canvas - The canvas the application renders into.
950
+ */
951
+ connect(app, canvas) {
952
+ this._generation++;
953
+ this._app = app;
954
+ this._canvas = canvas;
955
+ const { width, height } = app.graphicsDevice;
956
+ this._picker = new Picker(app, width, height);
957
+ // Create bound handlers but don't attach them yet. The move handler is async, so it is
958
+ // wrapped to discard the promise - a listener must not return one.
959
+ const listener = (handler) => {
960
+ return (event) => {
961
+ handler.call(this, event);
962
+ };
963
+ };
964
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
965
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
966
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
967
+ this._pointerHandlers.pointercancel = (event) => {
968
+ this._downPicks.delete(event.pointerId);
969
+ };
970
+ this.syncListeners();
971
+ }
972
+ /**
973
+ * Detaches the canvas listeners and drops every piece of pointer state, so nothing picked or
974
+ * queued before the teardown can affect a later boot. Safe to call on a controller that was
975
+ * never connected.
976
+ */
977
+ disconnect() {
978
+ this._generation++;
979
+ if (this._canvas) {
980
+ Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
981
+ if (handler) {
982
+ this._canvas.removeEventListener(type, handler);
983
+ }
984
+ });
985
+ }
986
+ this._app = null;
987
+ this._canvas = null;
988
+ this._picker = null;
989
+ this._hoveredEntity = null;
990
+ this._pointerHandlers = {
991
+ pointermove: null,
992
+ pointerdown: null,
993
+ pointerup: null,
994
+ pointercancel: null
995
+ };
996
+ this._downPicks.clear();
997
+ this._clickListened = false;
998
+ this._lastClick = null;
999
+ // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
1000
+ this._dispatchChain = Promise.resolve();
1001
+ }
1002
+ /**
1003
+ * Resizes the picker to the drawing buffer. The picker must track the buffer, or picks would
1004
+ * land at stale coordinates after a resize.
1005
+ *
1006
+ * @param width - The drawing buffer width.
1007
+ * @param height - The drawing buffer height.
1008
+ */
1009
+ resize(width, height) {
1010
+ this._picker?.resize(width, height);
1011
+ }
1012
+ /**
1013
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
1014
+ * detaches the rest. Called whenever a listener connects or disconnects anywhere under the
1015
+ * host element: several synthesized types can need the same canvas listener (enter, leave
1016
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
1017
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
1018
+ * a no-op by EventTarget semantics, so no attach state is kept. Does nothing before
1019
+ * {@link connect} - connecting syncs once the handlers exist.
1020
+ */
1021
+ syncListeners() {
1022
+ const canvas = this._canvas;
1023
+ if (!canvas)
1024
+ return;
1025
+ const elements = this._host.pointerTargets();
1026
+ const needed = new Set();
1027
+ for (const type of SYNTHESIZED_EVENTS) {
1028
+ if (elements.some((element) => element._hasListeners(type))) {
1029
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1030
+ }
1031
+ }
1032
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1033
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1034
+ if (!handler)
1035
+ return;
1036
+ if (needed.has(canvasType)) {
1037
+ canvas.addEventListener(canvasType, handler);
1038
+ }
1039
+ else {
1040
+ canvas.removeEventListener(canvasType, handler);
1041
+ }
1042
+ });
1043
+ }
1044
+ /**
1045
+ * Resolves the element that owns hover for a picked node: the nearest node up the parent
1046
+ * chain - starting with the node itself - whose element listens for any of the hover event
1047
+ * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1048
+ * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1049
+ * to hover rather than swallowing a listening ancestor's enter/leave pair.
1050
+ *
1051
+ * @param node - The picked node, or `null`.
1052
+ * @returns The hover-owning element, or `null`.
1053
+ */
1054
+ _hoverTarget(node) {
1055
+ while (node !== null) {
1056
+ const element = this._host.elementFromNode(node);
1057
+ if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1058
+ return element;
1059
+ }
1060
+ node = node.parent;
1061
+ }
1062
+ return null;
1063
+ }
1064
+ /**
1065
+ * Like {@link _hoverTarget}, but for one event type: skips elements without a listener for
1066
+ * `type`, so a hit on an unlistened child still reaches a listening ancestor.
1067
+ *
1068
+ * @param node - The picked node, or `null`.
1069
+ * @param type - The pointer event type a listener is required for.
1070
+ * @returns The nearest listening element, or `null`.
1071
+ */
1072
+ _elementWithListener(node, type) {
1073
+ while (node !== null) {
1074
+ const element = this._host.elementFromNode(node);
1075
+ if (element?._hasListeners(type)) {
1076
+ return element;
1077
+ }
1078
+ node = node.parent;
1079
+ }
1080
+ return null;
1081
+ }
1082
+ /**
1083
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1084
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1085
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1086
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1087
+ * coordinates correspond to no real geometry.
1088
+ *
1089
+ * @param event - The pointer event to convert.
1090
+ * @param canvas - The canvas the event was dispatched on.
1091
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
1092
+ */
1093
+ _getPickerCoordinates(event, canvas) {
1094
+ const canvasRect = canvas.getBoundingClientRect();
1095
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
1096
+ return { x: event.clientX, y: event.clientY, mapped: false };
1097
+ }
1098
+ const scaleX = canvas.width / canvasRect.width;
1099
+ const scaleY = canvas.height / canvasRect.height;
1100
+ return {
1101
+ x: (event.clientX - canvasRect.left) * scaleX,
1102
+ y: (event.clientY - canvasRect.top) * scaleY,
1103
+ mapped: true
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
1108
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1109
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1110
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1111
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1112
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1113
+ * holds nothing there.
1114
+ *
1115
+ * @param camera - The camera to test.
1116
+ * @param x - The x coordinate, in buffer space.
1117
+ * @param y - The y coordinate, in buffer space.
1118
+ * @param canvas - The canvas the coordinates are relative to.
1119
+ * @returns Whether the camera's viewport contains the point.
1120
+ */
1121
+ _cameraContains(camera, x, y, canvas) {
1122
+ const rect = camera.rect;
1123
+ const left = rect.x * canvas.width;
1124
+ const bottom = (1 - rect.y) * canvas.height;
1125
+ const top = bottom - rect.w * canvas.height;
1126
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1127
+ }
1128
+ /**
1129
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1130
+ *
1131
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1132
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1133
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1134
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
1135
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1136
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1137
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
1138
+ *
1139
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1140
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
1141
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1142
+ * async variant works on both backends and does not block the main thread on a GPU read.
1143
+ *
1144
+ * @param event - The pointer event to pick under.
1145
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
1146
+ */
1147
+ async _pickNode(event) {
1148
+ const generation = this._generation;
1149
+ const app = this._app;
1150
+ const picker = this._picker;
1151
+ const canvas = this._canvas;
1152
+ if (!app || !picker || !canvas)
1153
+ return null;
1154
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1155
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
1156
+ // renders last and sits on top. Read through .at() because a pick handler may remove
1157
+ // cameras while an earlier iteration's read back is in flight.
1158
+ const cameras = app.systems.camera?.cameras ?? [];
1159
+ for (let i = cameras.length - 1; i >= 0; i--) {
1160
+ const camera = cameras.at(i);
1161
+ // A camera rendering to a texture is not on the canvas.
1162
+ if (!camera || camera.renderTarget)
1163
+ continue;
1164
+ // Coordinates that could not be mapped cannot be tested for containment.
1165
+ if (mapped && !this._cameraContains(camera, x, y, canvas))
1166
+ continue;
1167
+ picker.prepare(camera, app.scene);
1168
+ const selection = await picker.getSelectionAsync(x, y);
1169
+ // The host may have disconnected - or disconnected and reconnected - while the read
1170
+ // back was in flight. Either way this pick's connection is gone.
1171
+ if (generation !== this._generation)
1172
+ return null;
1173
+ if (selection.length > 0) {
1174
+ const item = selection[0];
1175
+ return item instanceof MeshInstance ? item.node : item.entity;
1176
+ }
1177
+ // Nothing hit. A camera that clears the color buffer paints its background over
1178
+ // everything beneath it, so the miss is final; one that does not is an overlay
1179
+ // that the cameras beneath show through, so they get their turn.
1180
+ if (camera.clearColorBuffer)
1181
+ return null;
1182
+ }
1183
+ return null;
1184
+ }
1185
+ async _onPointerMove(event) {
1186
+ if (!this._picker || !this._app)
1187
+ return;
1188
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1189
+ // newest pick may update the hover state - an older one describes a pointer position the
1190
+ // user has already left, and one from an earlier connection describes a scene that no
1191
+ // longer exists.
1192
+ const generation = this._generation;
1193
+ const token = ++this._pickToken;
1194
+ const node = await this._pickNode(event);
1195
+ if (token !== this._pickToken || generation !== this._generation)
1196
+ return;
1197
+ // The hovered element is the nearest one up the node's parent chain with a hover
1198
+ // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1199
+ // type below: having any hover listener selects the target, each event needs its own.
1200
+ const newHoverEntity = this._hoverTarget(node);
1201
+ // Handle enter/leave events
1202
+ if (this._hoveredEntity !== newHoverEntity) {
1203
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1204
+ this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1205
+ }
1206
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1207
+ newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1208
+ }
1209
+ }
1210
+ // Update hover state
1211
+ this._hoveredEntity = newHoverEntity;
1212
+ // Handle pointermove event
1213
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1214
+ newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1215
+ }
1216
+ }
1217
+ /**
1218
+ * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
1219
+ * canvas event handler - the order of appends is what carries canvas-event order. A step
1220
+ * that rejects is reported and released, so the steps queued behind it still dispatch.
1221
+ *
1222
+ * @param step - The dispatch work to run once every earlier step has finished.
1223
+ */
1224
+ _chainDispatch(step) {
1225
+ this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
1226
+ console.error(error);
1227
+ });
1228
+ }
1229
+ _onPointerDown(event) {
1230
+ if (!this._picker || !this._app)
1231
+ return;
1232
+ const generation = this._generation;
1233
+ // Picks stay concurrent - only the dispatch of the results is serialized
1234
+ const pick = this._pickNode(event);
1235
+ // A click concludes on the matching pointerup, which needs to know what the press
1236
+ // picked. Primary button only - the only button a click can conclude from - and only
1237
+ // while click is listened for, since it is the click mapping that keeps the pointerup
1238
+ // and pointercancel listeners attached to clean the entry up again.
1239
+ if (this._clickListened && event.button === 0) {
1240
+ this._downPicks.set(event.pointerId, pick);
1241
+ }
1242
+ this._chainDispatch(async () => {
1243
+ const node = await pick;
1244
+ if (generation !== this._generation)
1245
+ return; // this press's connection is gone
1246
+ const entityElement = this._elementWithListener(node, 'pointerdown');
1247
+ if (entityElement) {
1248
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1249
+ }
1250
+ });
1251
+ }
1252
+ _onPointerUp(event) {
1253
+ if (!this._picker || !this._app)
1254
+ return;
1255
+ const generation = this._generation;
1256
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
1257
+ // entry is gone before any other event for this pointer can be handled.
1258
+ const downPick = this._downPicks.get(event.pointerId);
1259
+ this._downPicks.delete(event.pointerId);
1260
+ const pick = this._pickNode(event);
1261
+ this._chainDispatch(async () => {
1262
+ const node = await pick;
1263
+ if (generation !== this._generation)
1264
+ return; // this release's connection is gone
1265
+ const entityElement = this._elementWithListener(node, 'pointerup');
1266
+ if (entityElement) {
1267
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1268
+ }
1269
+ });
1270
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1271
+ // what the press and the release picked, for the primary button only. Appended after
1272
+ // the release's own step, so it dispatches after the pointerup that concludes it.
1273
+ if (!downPick || event.button !== 0)
1274
+ return;
1275
+ this._chainDispatch(async () => {
1276
+ // A rejected pick was already reported by the press or release step that awaited it;
1277
+ // here it just means no click can conclude.
1278
+ const picked = await Promise.all([downPick, pick]).catch(() => null);
1279
+ if (!picked || generation !== this._generation)
1280
+ return;
1281
+ const [downNode, upNode] = picked;
1282
+ const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
1283
+ if (clickElement) {
1284
+ const click = new PointerEvent('click', event);
1285
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1286
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
1287
+ // platform chains it (same target, within the double-click window). Overridden
1288
+ // with defineProperty because an event instance used as an init dict cannot have
1289
+ // single fields replaced.
1290
+ const time = performance.now();
1291
+ const last = this._lastClick;
1292
+ const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1293
+ this._lastClick = { element: clickElement, time, count };
1294
+ Object.defineProperty(click, 'detail', { value: count });
1295
+ clickElement.dispatchEvent(click);
1296
+ }
1297
+ });
1298
+ }
1299
+ }
1300
+
908
1301
  /**
909
1302
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
910
1303
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -988,31 +1381,16 @@ class AppElement extends AsyncElement {
988
1381
  * engine scene nodes back to their owning elements by identity - never by name.
989
1382
  */
990
1383
  _entityElements = new Map();
991
- _picker = null;
992
- _hoveredEntity = null;
993
- // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
994
- _pickToken = 0;
995
- _pointerHandlers = {
996
- pointermove: null,
997
- pointerdown: null,
998
- pointerup: null,
999
- pointercancel: null
1000
- };
1001
- /**
1002
- * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
1003
- * may still conclude it. The promise is stored rather than its result, so a release can
1004
- * await a press pick that has not resolved yet. Entries are removed by the matching
1005
- * pointerup or pointercancel, and only ever stored while some element listens for click -
1006
- * which is also what keeps those two canvas listeners attached.
1007
- */
1008
- _downPicks = new Map();
1009
- /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
1010
- _clickListened = false;
1011
1384
  /**
1012
- * The previous click's target, time and count, for chaining successive clicks into the
1013
- * click count that `detail` carries. `null` until a click has fired.
1385
+ * The pointer-input subsystem: the picker, the canvas handlers, and the synthesized-event
1386
+ * dispatch. The element drives its lifecycle (connect on boot, resize with the drawing
1387
+ * buffer, listener syncs, disconnect on teardown) and hands it the two lookups it needs -
1388
+ * everything else about pointer input lives in the controller.
1014
1389
  */
1015
- _lastClick = null;
1390
+ _pointer = new PointerController({
1391
+ elementFromNode: (node) => this._entityElements.get(node) ?? null,
1392
+ pointerTargets: () => Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'))
1393
+ });
1016
1394
  _app = null;
1017
1395
  _loadProgress = 0;
1018
1396
  /**
@@ -1051,8 +1429,8 @@ class AppElement extends AsyncElement {
1051
1429
  // entities. Registered once here rather than on every boot - the sync no-ops while there
1052
1430
  // is no canvas, and a re-booted element must not stack a second set.
1053
1431
  SYNTHESIZED_EVENTS.forEach((type) => {
1054
- this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1055
- this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
1432
+ this.addEventListener(`${type}:connect`, () => this._pointer.syncListeners());
1433
+ this.addEventListener(`${type}:disconnect`, () => this._pointer.syncListeners());
1056
1434
  });
1057
1435
  }
1058
1436
  async connectedCallback() {
@@ -1226,7 +1604,7 @@ class AppElement extends AsyncElement {
1226
1604
  // from the canvas's client size
1227
1605
  app.setCanvasFillMode(FILLMODE_NONE);
1228
1606
  app.setCanvasResolution(RESOLUTION_AUTO);
1229
- this._pickerCreate();
1607
+ this._pointer.connect(app, this._canvas);
1230
1608
  // Track the element's box rather than the window: containers resize without any window
1231
1609
  // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1232
1610
  // ResizeObserver - there, the resolution set above is the only sizing that happens.
@@ -1318,7 +1696,7 @@ class AppElement extends AsyncElement {
1318
1696
  // instead of completing against a torn-down element.
1319
1697
  this._bootGeneration++;
1320
1698
  this._optionsLocked = false;
1321
- this._pickerDestroy();
1699
+ this._pointer.disconnect();
1322
1700
  // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1323
1701
  // unregister them - clear() covers any entity the engine no longer reached.
1324
1702
  if (this._app) {
@@ -1354,49 +1732,7 @@ class AppElement extends AsyncElement {
1354
1732
  }
1355
1733
  this.app.updateCanvasSize();
1356
1734
  const { width, height } = this.app.graphicsDevice;
1357
- this._picker?.resize(width, height);
1358
- }
1359
- _pickerCreate() {
1360
- const { width, height } = this.app.graphicsDevice;
1361
- this._picker = new Picker(this.app, width, height);
1362
- // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
1363
- // each is wrapped to discard the promise - a listener must not return one, and nothing
1364
- // awaits the result.
1365
- const listener = (handler) => {
1366
- return (event) => {
1367
- handler.call(this, event);
1368
- };
1369
- };
1370
- this._pointerHandlers.pointermove = listener(this._onPointerMove);
1371
- this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1372
- this._pointerHandlers.pointerup = listener(this._onPointerUp);
1373
- this._pointerHandlers.pointercancel = (event) => {
1374
- this._downPicks.delete(event.pointerId);
1375
- };
1376
- // Attach canvas listeners for element listeners registered before this boot (e.g.
1377
- // handlers created from inline attributes when their elements were first upgraded, or
1378
- // listeners carried over from before a re-boot)
1379
- this._syncCanvasListeners();
1380
- }
1381
- _pickerDestroy() {
1382
- if (this._canvas) {
1383
- Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
1384
- if (handler) {
1385
- this._canvas.removeEventListener(type, handler);
1386
- }
1387
- });
1388
- }
1389
- this._picker = null;
1390
- this._hoveredEntity = null;
1391
- this._pointerHandlers = {
1392
- pointermove: null,
1393
- pointerdown: null,
1394
- pointerup: null,
1395
- pointercancel: null
1396
- };
1397
- this._downPicks.clear();
1398
- this._clickListened = false;
1399
- this._lastClick = null;
1735
+ this._pointer.resize(width, height);
1400
1736
  }
1401
1737
  /**
1402
1738
  * Registers the element that fronts an entity. Called by EntityElement when it creates its
@@ -1424,268 +1760,11 @@ class AppElement extends AsyncElement {
1424
1760
  * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1425
1761
  * through the engine API.
1426
1762
  *
1427
- * @param entity - The entity to look up.
1428
- * @returns The element fronting the entity, or `null`.
1429
- */
1430
- elementFromEntity(entity) {
1431
- return this._entityElements.get(entity) ?? null;
1432
- }
1433
- /**
1434
- * Resolves the element that owns hover for a picked node: the nearest node up the parent
1435
- * chain - starting with the node itself - whose element listens for any of the hover event
1436
- * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1437
- * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1438
- * to hover rather than swallowing a listening ancestor's enter/leave pair.
1439
- *
1440
- * @param node - The picked node, or `null`.
1441
- * @returns The hover-owning element, or `null`.
1442
- */
1443
- _hoverTarget(node) {
1444
- while (node !== null) {
1445
- const element = this._entityElements.get(node);
1446
- if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1447
- return element;
1448
- }
1449
- node = node.parent;
1450
- }
1451
- return null;
1452
- }
1453
- /**
1454
- * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1455
- * on an unlistened child still reaches a listening ancestor.
1456
- *
1457
- * @param node - The picked node, or `null`.
1458
- * @param type - The pointer event type a listener is required for.
1459
- * @returns The nearest listening element, or `null`.
1460
- */
1461
- _elementWithListener(node, type) {
1462
- while (node !== null) {
1463
- const element = this._entityElements.get(node);
1464
- if (element?._hasListeners(type)) {
1465
- return element;
1466
- }
1467
- node = node.parent;
1468
- }
1469
- return null;
1470
- }
1471
- /**
1472
- * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1473
- * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1474
- * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1475
- * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1476
- * coordinates correspond to no real geometry.
1477
- *
1478
- * @param event - The pointer event to convert.
1479
- * @param canvas - The canvas the event was dispatched on.
1480
- * @returns The buffer-space coordinates, and whether they were actually mapped.
1481
- */
1482
- _getPickerCoordinates(event, canvas) {
1483
- const canvasRect = canvas.getBoundingClientRect();
1484
- if (canvasRect.width === 0 || canvasRect.height === 0) {
1485
- return { x: event.clientX, y: event.clientY, mapped: false };
1486
- }
1487
- const scaleX = canvas.width / canvasRect.width;
1488
- const scaleY = canvas.height / canvasRect.height;
1489
- return {
1490
- x: (event.clientX - canvasRect.left) * scaleX,
1491
- y: (event.clientY - canvasRect.top) * scaleY,
1492
- mapped: true
1493
- };
1494
- }
1495
- /**
1496
- * Whether a camera's viewport contains the point. A camera renders into its normalized
1497
- * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1498
- * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1499
- * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1500
- * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1501
- * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1502
- * holds nothing there.
1503
- *
1504
- * @param camera - The camera to test.
1505
- * @param x - The x coordinate, in buffer space.
1506
- * @param y - The y coordinate, in buffer space.
1507
- * @param canvas - The canvas the coordinates are relative to.
1508
- * @returns Whether the camera's viewport contains the point.
1509
- */
1510
- _cameraContains(camera, x, y, canvas) {
1511
- const rect = camera.rect;
1512
- const left = rect.x * canvas.width;
1513
- const bottom = (1 - rect.y) * canvas.height;
1514
- const top = bottom - rect.w * canvas.height;
1515
- return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1516
- }
1517
- /**
1518
- * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1519
- *
1520
- * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1521
- * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1522
- * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1523
- * contain the pointer. A camera that picks nothing ends the search if it clears the color
1524
- * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1525
- * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1526
- * The pick buffer is prepared per camera, so each camera picks from its own layers.
1527
- *
1528
- * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1529
- * supported on WebGPU, where it returns an empty selection rather than failing - which
1530
- * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
1531
- * async variant works on both backends and does not block the main thread on a GPU read.
1532
- *
1533
- * @param event - The pointer event to pick under.
1534
- * @returns The graph node under the pointer, or `null` if nothing was hit.
1535
- */
1536
- async _pickNode(event) {
1537
- const app = this.app;
1538
- const picker = this._picker;
1539
- const canvas = this._canvas;
1540
- if (!app || !picker || !canvas)
1541
- return null;
1542
- const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1543
- // Walked from the end: the array is sorted by ascending priority, so the last camera
1544
- // renders last and sits on top. Read through .at() because a pick handler may remove
1545
- // cameras while an earlier iteration's read back is in flight.
1546
- const cameras = app.systems.camera?.cameras ?? [];
1547
- for (let i = cameras.length - 1; i >= 0; i--) {
1548
- const camera = cameras.at(i);
1549
- // A camera rendering to a texture is not on the canvas.
1550
- if (!camera || camera.renderTarget)
1551
- continue;
1552
- // Coordinates that could not be mapped cannot be tested for containment.
1553
- if (mapped && !this._cameraContains(camera, x, y, canvas))
1554
- continue;
1555
- picker.prepare(camera, app.scene);
1556
- const selection = await picker.getSelectionAsync(x, y);
1557
- // The element may have disconnected while the read back was in flight.
1558
- if (!this._picker || !this.app)
1559
- return null;
1560
- if (selection.length > 0) {
1561
- const item = selection[0];
1562
- return item instanceof MeshInstance ? item.node : item.entity;
1563
- }
1564
- // Nothing hit. A camera that clears the color buffer paints its background over
1565
- // everything beneath it, so the miss is final; one that does not is an overlay
1566
- // that the cameras beneath show through, so they get their turn.
1567
- if (camera.clearColorBuffer)
1568
- return null;
1569
- }
1570
- return null;
1571
- }
1572
- async _onPointerMove(event) {
1573
- if (!this._picker || !this.app)
1574
- return;
1575
- // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1576
- // newest pick may update the hover state - an older one describes a pointer position the
1577
- // user has already left.
1578
- const token = ++this._pickToken;
1579
- const node = await this._pickNode(event);
1580
- if (token !== this._pickToken || !this._picker)
1581
- return;
1582
- // The hovered element is the nearest one up the node's parent chain with a hover
1583
- // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1584
- // type below: having any hover listener selects the target, each event needs its own.
1585
- const newHoverEntity = this._hoverTarget(node);
1586
- // Handle enter/leave events
1587
- if (this._hoveredEntity !== newHoverEntity) {
1588
- if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1589
- this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1590
- }
1591
- if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1592
- newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1593
- }
1594
- }
1595
- // Update hover state
1596
- this._hoveredEntity = newHoverEntity;
1597
- // Handle pointermove event
1598
- if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1599
- newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1600
- }
1601
- }
1602
- async _onPointerDown(event) {
1603
- if (!this._picker || !this.app)
1604
- return;
1605
- const pick = this._pickNode(event);
1606
- // A click concludes on the matching pointerup, which needs to know what the press
1607
- // picked. Primary button only - the only button a click can conclude from - and only
1608
- // while click is listened for, since it is the click mapping that keeps the pointerup
1609
- // and pointercancel listeners attached to clean the entry up again.
1610
- if (this._clickListened && event.button === 0) {
1611
- this._downPicks.set(event.pointerId, pick);
1612
- }
1613
- const node = await pick;
1614
- if (!this._picker)
1615
- return; // the element disconnected while the pick was in flight
1616
- const entityElement = this._elementWithListener(node, 'pointerdown');
1617
- if (entityElement) {
1618
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1619
- }
1620
- }
1621
- async _onPointerUp(event) {
1622
- if (!this._picker || !this.app)
1623
- return;
1624
- // The press pick this release may conclude as a click. Claimed synchronously, so the
1625
- // entry is gone before any other event for this pointer can be handled.
1626
- const downPick = this._downPicks.get(event.pointerId);
1627
- this._downPicks.delete(event.pointerId);
1628
- const node = await this._pickNode(event);
1629
- if (!this._picker)
1630
- return; // the element disconnected while the pick was in flight
1631
- const entityElement = this._elementWithListener(node, 'pointerup');
1632
- if (entityElement) {
1633
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1634
- }
1635
- // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1636
- // what the press and the release picked, for the primary button only. The press pick
1637
- // may still be in flight - a quick tap resolves in pick order, not event order.
1638
- if (!downPick || event.button !== 0)
1639
- return;
1640
- const downNode = await downPick;
1641
- if (!this._picker)
1642
- return;
1643
- const clickElement = this._elementWithListener(commonAncestor(downNode, node), 'click');
1644
- if (clickElement) {
1645
- const click = new PointerEvent('click', event);
1646
- // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1647
- // at 0 - but click is exempt: its detail is the click count, chained here as the
1648
- // platform chains it (same target, within the double-click window). Overridden
1649
- // with defineProperty because an event instance used as an init dict cannot have
1650
- // single fields replaced.
1651
- const time = performance.now();
1652
- const last = this._lastClick;
1653
- const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1654
- this._lastClick = { element: clickElement, time, count };
1655
- Object.defineProperty(click, 'detail', { value: count });
1656
- clickElement.dispatchEvent(click);
1657
- }
1658
- }
1659
- /**
1660
- * Attaches exactly the canvas listeners the tree's current element listeners need, and
1661
- * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1662
- * this element: several synthesized types can need the same canvas listener (enter, leave
1663
- * and move all ride the move pick; click rides the down/up pair), so one type's removal
1664
- * must not detach a listener another type still uses. Re-attaching an attached listener is
1665
- * a no-op by EventTarget semantics, so no attach state is kept.
1763
+ * @param entity - The entity to look up.
1764
+ * @returns The element fronting the entity, or `null`.
1666
1765
  */
1667
- _syncCanvasListeners() {
1668
- const canvas = this._canvas;
1669
- if (!canvas)
1670
- return; // not booted yet: _pickerCreate syncs once the handlers exist
1671
- const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1672
- const needed = new Set();
1673
- for (const type of SYNTHESIZED_EVENTS) {
1674
- if (elements.some((element) => element._hasListeners(type))) {
1675
- canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1676
- }
1677
- }
1678
- this._clickListened = elements.some((element) => element._hasListeners('click'));
1679
- Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1680
- if (!handler)
1681
- return;
1682
- if (needed.has(canvasType)) {
1683
- canvas.addEventListener(canvasType, handler);
1684
- }
1685
- else {
1686
- canvas.removeEventListener(canvasType, handler);
1687
- }
1688
- });
1766
+ elementFromEntity(entity) {
1767
+ return this._entityElements.get(entity) ?? null;
1689
1768
  }
1690
1769
  /**
1691
1770
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -3087,6 +3166,108 @@ const useAsset = (id) => {
3087
3166
  return asset;
3088
3167
  };
3089
3168
 
3169
+ /**
3170
+ * Watches the asset currently selected by an element.
3171
+ *
3172
+ * An element can change its asset while the old one is still loading, or disconnect before the
3173
+ * load finishes. This class makes sure callbacks from those older loads do nothing. Calling
3174
+ * {@link bind} stops watching the previous asset and starts watching the new one. Calling
3175
+ * {@link cancel} stops watching altogether.
3176
+ *
3177
+ * Asset lookup goes through {@link useAsset}, so selecting a lazy asset starts its load. The
3178
+ * caller still decides what to do with the result, such as creating scene content, reporting an
3179
+ * error, or marking an element ready.
3180
+ *
3181
+ * Reuse one `AssetBinding` for each asset-valued property throughout the element's lifetime. It
3182
+ * is safe for a callback to call `bind` again: the new asset remains active after the callback
3183
+ * returns.
3184
+ *
3185
+ * @internal
3186
+ */
3187
+ class AssetBinding {
3188
+ /**
3189
+ * Each bind or cancel gets a new number. Event handlers remember the number they were created
3190
+ * with and return if it is no longer current. Old listeners are normally removed as well, but
3191
+ * this check also protects against an event that was already in progress when removal
3192
+ * happened.
3193
+ */
3194
+ _generation = 0;
3195
+ _loadHandle = null;
3196
+ _errorHandle = null;
3197
+ _detach() {
3198
+ this._loadHandle?.off();
3199
+ this._loadHandle = null;
3200
+ this._errorHandle?.off();
3201
+ this._errorHandle = null;
3202
+ }
3203
+ /**
3204
+ * Stops watching the current asset and prevents its callbacks from running. The binding can
3205
+ * be used again by calling {@link bind}.
3206
+ */
3207
+ cancel() {
3208
+ this._generation++;
3209
+ this._detach();
3210
+ }
3211
+ /**
3212
+ * Starts watching the asset registered under `id` and stops watching the previous one. A
3213
+ * missing `id` still clears the previous binding. Looking up a lazy asset starts its load.
3214
+ *
3215
+ * If the asset has already loaded successfully, `load` runs before this method returns. An
3216
+ * earlier failure calls `error` immediately when that callback is provided; without one, the
3217
+ * binding waits for a later successful reload. Assets still loading are watched for the same
3218
+ * two outcomes. Once an event is handled, both listeners are removed.
3219
+ *
3220
+ * @param id - The `id` of the `<pc-asset>` element to bind to.
3221
+ * @param callbacks - Functions to call when loading succeeds or fails.
3222
+ * @returns The selected asset, or `undefined` if no asset has this `id`. The caller decides
3223
+ * how to handle a missing asset.
3224
+ */
3225
+ bind(id, callbacks) {
3226
+ const generation = ++this._generation;
3227
+ this._detach();
3228
+ const asset = useAsset(id);
3229
+ if (!asset) {
3230
+ return undefined;
3231
+ }
3232
+ const { error } = callbacks;
3233
+ if (asset.loaded) {
3234
+ // PlayCanvas sets `loaded` after both success and failure, so a resource must also be
3235
+ // present before this counts as success. Use `!= null` deliberately: `undefined` and
3236
+ // `null` both mean there is no resource, while a valid resource can still be falsy
3237
+ // (for example, an empty text file produces '').
3238
+ if (asset.resource != null) {
3239
+ callbacks.load(asset);
3240
+ return asset;
3241
+ }
3242
+ if (error) {
3243
+ error(`asset '${id}' failed to load`);
3244
+ return asset;
3245
+ }
3246
+ }
3247
+ // Old listeners are normally removed by bind or cancel. The number check is a second
3248
+ // safeguard for a late event. Check it before _detach so an old callback cannot remove
3249
+ // the listeners for the current asset. Whichever current event runs first removes both
3250
+ // listeners.
3251
+ this._loadHandle = asset.once('load', () => {
3252
+ if (generation !== this._generation) {
3253
+ return;
3254
+ }
3255
+ this._detach();
3256
+ callbacks.load(asset);
3257
+ });
3258
+ if (error) {
3259
+ this._errorHandle = asset.once('error', (err) => {
3260
+ if (generation !== this._generation) {
3261
+ return;
3262
+ }
3263
+ this._detach();
3264
+ error(err);
3265
+ });
3266
+ }
3267
+ return asset;
3268
+ }
3269
+ }
3270
+
3090
3271
  /**
3091
3272
  * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
3092
3273
  * name is shared by several nodes in the model, the attached component types, and the material
@@ -3187,18 +3368,18 @@ class ModelElement extends EntityOwnerElement {
3187
3368
  _contentEntity = null;
3188
3369
  /**
3189
3370
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
3190
- * by a load when it starts. A load that resumes from an await or a load callback abandons
3191
- * itself if the value has moved on, so a superseded load can neither instantiate a second
3192
- * content root nor parent one under a host a newer cycle has already replaced.
3371
+ * by a load when it starts. A load that resumes from an await abandons itself if the value
3372
+ * has moved on, so a superseded load can neither instantiate a second content root nor
3373
+ * parent one under a host a newer cycle has already replaced. The asset subscription itself
3374
+ * is guarded by the binding below.
3193
3375
  */
3194
3376
  _loadGeneration = 0;
3195
3377
  /**
3196
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
3197
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
3198
- * leave them registered until the asset settles (or forever, if it never does).
3378
+ * The subscription to the current container asset while it is loading. Whatever supersedes
3379
+ * the load a newer load, a disconnect, the host dying — cancels it, so the asset settling
3380
+ * later cannot deliver to a load that no longer owns the element.
3199
3381
  */
3200
- _loadHandle = null;
3201
- _errorHandle = null;
3382
+ _binding = new AssetBinding();
3202
3383
  /**
3203
3384
  * The root entity of the instantiated model content, parented beneath the host entity.
3204
3385
  * `null` until the container asset has loaded and been instantiated, after a failed load,
@@ -3289,7 +3470,7 @@ class ModelElement extends EntityOwnerElement {
3289
3470
  // resets the element. The generation guard comes first so a load suspended on an await
3290
3471
  // cannot resume against the torn-down element.
3291
3472
  this._loadGeneration++;
3292
- this._detachLoadHandlers();
3473
+ this._binding.cancel();
3293
3474
  this._entity?.destroy();
3294
3475
  }
3295
3476
  /**
@@ -3308,16 +3489,10 @@ class ModelElement extends EntityOwnerElement {
3308
3489
  */
3309
3490
  _onEntityDestroy(entity) {
3310
3491
  this._loadGeneration++;
3311
- this._detachLoadHandlers();
3492
+ this._binding.cancel();
3312
3493
  this._contentEntity = null;
3313
3494
  super._onEntityDestroy(entity);
3314
3495
  }
3315
- _detachLoadHandlers() {
3316
- this._loadHandle?.off();
3317
- this._loadHandle = null;
3318
- this._errorHandle?.off();
3319
- this._errorHandle = null;
3320
- }
3321
3496
  /**
3322
3497
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
3323
3498
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -3344,7 +3519,7 @@ class ModelElement extends EntityOwnerElement {
3344
3519
  this._destroyContent();
3345
3520
  // Supersede any load already in flight - only the newest load may instantiate
3346
3521
  const generation = ++this._loadGeneration;
3347
- this._detachLoadHandlers();
3522
+ this._binding.cancel();
3348
3523
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
3349
3524
  // content. A no-op on first connection, where readiness is still pending.
3350
3525
  this._resetReady();
@@ -3370,39 +3545,23 @@ class ModelElement extends EntityOwnerElement {
3370
3545
  this._onReady();
3371
3546
  return;
3372
3547
  }
3373
- const asset = useAsset(this._asset);
3374
- if (!asset) {
3375
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3376
- // silently pending.
3377
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3378
- return;
3379
- }
3380
- if (asset.loaded) {
3381
- this._instantiate(asset.resource);
3382
- }
3383
- else {
3384
- // The generation is re-checked even though a superseded handler is detached: the
3385
- // detach relies on how the engine's event emitter treats removal, while the check
3386
- // holds on its own. Whichever of load/error fires first detaches the other.
3387
- this._loadHandle = asset.once('load', () => {
3388
- this._detachLoadHandlers();
3389
- if (generation !== this._loadGeneration) {
3390
- return;
3391
- }
3392
- this._instantiate(asset.resource);
3393
- });
3394
- this._errorHandle = asset.once('error', (err) => {
3395
- this._detachLoadHandlers();
3396
- if (generation !== this._loadGeneration) {
3397
- return;
3398
- }
3548
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
3549
+ // delivery below is always current - no generation re-check needed in the callbacks.
3550
+ const asset = this._binding.bind(this._asset, {
3551
+ load: ({ resource }) => this._instantiate(resource),
3552
+ error: (err) => {
3399
3553
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
3400
3554
  // readiness means the load settled, not that it succeeded.
3401
3555
  this.dispatchEvent(new ErrorEvent('error', {
3402
3556
  message: err instanceof Error ? err.message : String(err)
3403
3557
  }));
3404
3558
  this._onReady();
3405
- });
3559
+ }
3560
+ });
3561
+ if (!asset) {
3562
+ // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3563
+ // silently pending.
3564
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3406
3565
  }
3407
3566
  }
3408
3567
  /**
@@ -3537,6 +3696,8 @@ class ComponentElement extends AsyncElement {
3537
3696
  console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
3538
3697
  return;
3539
3698
  }
3699
+ // The name passed by the subclass selects the engine system that creates its T - a
3700
+ // pairing the type system cannot express, so it is asserted this once
3540
3701
  this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
3541
3702
  }
3542
3703
  async _addComponent() {
@@ -3799,6 +3960,9 @@ class AnimComponentElement extends ComponentElement {
3799
3960
  */
3800
3961
  _applyRootBone() {
3801
3962
  const component = this.component;
3963
+ if (!component) {
3964
+ return;
3965
+ }
3802
3966
  // A non-null root this element did not assign came through the engine API. A fresh
3803
3967
  // component starts at null, which is always reclaimable.
3804
3968
  if (component.rootBone !== null && component.rootBone !== this._managedRootBone) {
@@ -3871,7 +4035,7 @@ class AnimComponentElement extends ComponentElement {
3871
4035
  * declared `clip` selection can apply before any asset has loaded.
3872
4036
  */
3873
4037
  _assignClip(clip) {
3874
- this.component.assignAnimation(clip.name, clip._track ?? AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
4038
+ this.component?.assignAnimation(clip.name, clip._track ?? AnimTrack.EMPTY, undefined, clip.speed, clip.loop);
3875
4039
  }
3876
4040
  /**
3877
4041
  * Validates a clip child and, when valid, assigns its state and starts its track resolution.
@@ -4179,8 +4343,9 @@ class AnimComponentElement extends ComponentElement {
4179
4343
  component.playing = true;
4180
4344
  }
4181
4345
  /**
4182
- * Gets the underlying PlayCanvas anim component.
4183
- * @returns The anim component.
4346
+ * Gets the underlying PlayCanvas anim component. `null` until the element is
4347
+ * ready see {@link ComponentElement.component}.
4348
+ * @returns The anim component, or `null`.
4184
4349
  */
4185
4350
  get component() {
4186
4351
  return super.component;
@@ -4329,6 +4494,11 @@ class AnimClipElement extends AsyncElement {
4329
4494
  */
4330
4495
  _animElement = null;
4331
4496
  _asset = '';
4497
+ /**
4498
+ * Watches the current source asset while it loads. Starting a new resolution or
4499
+ * disconnecting cancels it, so a superseded source can never hand its track to the parent.
4500
+ */
4501
+ _binding = new AssetBinding();
4332
4502
  /**
4333
4503
  * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
4334
4504
  * a resume from an await abandons itself if the value has moved on, so a stale callback can
@@ -4336,19 +4506,13 @@ class AnimClipElement extends AsyncElement {
4336
4506
  * own callback.
4337
4507
  */
4338
4508
  _connectionGeneration = 0;
4339
- _errorHandle = null;
4340
4509
  /**
4341
4510
  * Incremented on every track resolution and on disconnect, and captured by a resolution when
4342
- * it starts. A resolution that resumes from an await or an asset callback abandons itself if
4343
- * the value has moved on, so a superseded resolution cannot hand a stale track to the parent.
4511
+ * it starts. A resolution that resumes from an await abandons itself if the value has moved
4512
+ * on, so a superseded resolution cannot hand a stale track to the parent. The asset
4513
+ * subscription itself is guarded by the binding above.
4344
4514
  */
4345
4515
  _loadGeneration = 0;
4346
- /**
4347
- * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
4348
- * Held so that whatever supersedes the resolution can detach the handlers from the asset,
4349
- * rather than leave them registered until the asset settles (or forever, if it never does).
4350
- */
4351
- _loadHandle = null;
4352
4516
  _loop = true;
4353
4517
  _name = '';
4354
4518
  _speed = 1;
@@ -4388,7 +4552,7 @@ class AnimClipElement extends AsyncElement {
4388
4552
  // Invalidate any connectedCallback or track resolution still suspended on an await
4389
4553
  this._connectionGeneration++;
4390
4554
  this._loadGeneration++;
4391
- this._detachLoadHandlers();
4555
+ this._binding.cancel();
4392
4556
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
4393
4557
  // by now. The component itself is null if the whole <pc-app> is being torn down —
4394
4558
  // parents disconnect first and have already removed the component.
@@ -4406,12 +4570,6 @@ class AnimClipElement extends AsyncElement {
4406
4570
  }
4407
4571
  return animElement;
4408
4572
  }
4409
- _detachLoadHandlers() {
4410
- this._loadHandle?.off();
4411
- this._loadHandle = null;
4412
- this._errorHandle?.off();
4413
- this._errorHandle = null;
4414
- }
4415
4573
  /**
4416
4574
  * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
4417
4575
  *
@@ -4437,34 +4595,19 @@ class AnimClipElement extends AsyncElement {
4437
4595
  async _resolveTrack(animElement) {
4438
4596
  this._animElement = animElement;
4439
4597
  const generation = ++this._loadGeneration;
4440
- this._detachLoadHandlers();
4598
+ this._binding.cancel();
4441
4599
  if (this._asset) {
4442
- const asset = useAsset(this._asset);
4600
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
4601
+ // delivery below is always current.
4602
+ const asset = this._binding.bind(this._asset, {
4603
+ load: (loaded) => this._extractTrack(loaded, `asset '${this._asset}'`),
4604
+ error: () => {
4605
+ this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4606
+ }
4607
+ });
4443
4608
  if (!asset) {
4444
4609
  this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
4445
- return;
4446
- }
4447
- if (asset.loaded) {
4448
- this._extractTrack(asset, `asset '${this._asset}'`);
4449
- return;
4450
4610
  }
4451
- // Whichever of load/error fires first detaches the other. The generation is
4452
- // re-checked even though a superseded handler is detached: the detach relies on how
4453
- // the engine's event emitter treats removal, while the check holds on its own.
4454
- this._loadHandle = asset.once('load', () => {
4455
- this._detachLoadHandlers();
4456
- if (generation !== this._loadGeneration) {
4457
- return;
4458
- }
4459
- this._extractTrack(asset, `asset '${this._asset}'`);
4460
- });
4461
- this._errorHandle = asset.once('error', () => {
4462
- this._detachLoadHandlers();
4463
- if (generation !== this._loadGeneration) {
4464
- return;
4465
- }
4466
- this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4467
- });
4468
4611
  return;
4469
4612
  }
4470
4613
  const model = animElement.parentElement;
@@ -4656,8 +4799,9 @@ class AudioListenerComponentElement extends ComponentElement {
4656
4799
  super('audiolistener');
4657
4800
  }
4658
4801
  /**
4659
- * Gets the underlying PlayCanvas audio listener component.
4660
- * @returns The audio listener component.
4802
+ * Gets the underlying PlayCanvas audio listener component. `null` until the element is
4803
+ * ready see {@link ComponentElement.component}.
4804
+ * @returns The audio listener component, or `null`.
4661
4805
  */
4662
4806
  get component() {
4663
4807
  return super.component;
@@ -4665,6 +4809,227 @@ class AudioListenerComponentElement extends ComponentElement {
4665
4809
  }
4666
4810
  customElements.define('pc-audio-listener', AudioListenerComponentElement);
4667
4811
 
4812
+ /**
4813
+ * Resolves entity reference strings against the document. A reference beginning with `#` is a
4814
+ * document-wide selector (an element id, or any selector rooted in one); anything else is an
4815
+ * entity name, resolved lexically through the entity hierarchy first and against the document
4816
+ * after — never as a selector or an id.
4817
+ *
4818
+ * Unlike the attribute parsers in `parse.ts`, these helpers touch the DOM, and `findEntityElement`
4819
+ * and `getEntity` return `null` instead of falling back to a default. They also do not warn -
4820
+ * what an unresolved reference means depends on the element holding it - so elements report
4821
+ * through `resolveEntity`, which takes that meaning as parameters, and `pc-script` builds its own
4822
+ * message from the lower-level pieces.
4823
+ */
4824
+ /**
4825
+ * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
4826
+ * arbitrary author text, so a lookup must fail to `null`, never throw.
4827
+ *
4828
+ * @param selector - The selector to query.
4829
+ * @returns The matched element, or `null`.
4830
+ */
4831
+ const query = (selector) => {
4832
+ try {
4833
+ return document.querySelector(selector);
4834
+ }
4835
+ catch {
4836
+ return null;
4837
+ }
4838
+ };
4839
+ /**
4840
+ * Runs a lookup against one scope, checking the scope element itself before its subtree — a
4841
+ * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
4842
+ * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
4843
+ * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
4844
+ * lookup must fail to `null`, never throw.
4845
+ *
4846
+ * @param scope - The element whose inclusive subtree to search.
4847
+ * @param selector - The selector to query.
4848
+ * @returns The matched element, or `null`.
4849
+ */
4850
+ const queryScope = (scope, selector) => {
4851
+ try {
4852
+ return scope.matches(selector) ? scope : scope.querySelector(selector);
4853
+ }
4854
+ catch {
4855
+ return null;
4856
+ }
4857
+ };
4858
+ /**
4859
+ * Reads the entity a resolved element is backing, through the `entity` accessor every
4860
+ * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
4861
+ *
4862
+ * @param element - The element to read, or `null`.
4863
+ * @returns The backing entity, or `null`.
4864
+ */
4865
+ const entityOf = (element) => {
4866
+ return element?.entity ?? null;
4867
+ };
4868
+ /**
4869
+ * The elements that front an entity: what a bare name can resolve to, and the scopes of the
4870
+ * lexical name lookup.
4871
+ */
4872
+ const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'];
4873
+ /**
4874
+ * The entity-fronting elements as one selector, for the scope walk.
4875
+ */
4876
+ const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
4877
+ /**
4878
+ * Resolves a reference string to the element it names. The grammar is closed — every reference
4879
+ * has exactly one interpretation:
4880
+ *
4881
+ * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
4882
+ * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
4883
+ * runs for it, so an unusually named entity cannot shadow it.
4884
+ * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
4885
+ * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
4886
+ * never interpreted as a selector or an element id, so adding or renaming elements can never
4887
+ * change which form it takes.
4888
+ *
4889
+ * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
4890
+ * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
4891
+ * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
4892
+ * entities by name — every clone resolves within itself before a document-wide lookup could reach
4893
+ * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
4894
+ * scope.
4895
+ *
4896
+ * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
4897
+ * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
4898
+ * something matches but is not backing an entity (yet, or ever).
4899
+ *
4900
+ * @param ref - The reference string to resolve.
4901
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4902
+ * name lookup. Omitted, the name lookup is document-wide only.
4903
+ * @returns The matched element, or `null`.
4904
+ * @internal
4905
+ */
4906
+ const findEntityElement = (ref, from) => {
4907
+ if (!ref) {
4908
+ return null;
4909
+ }
4910
+ // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
4911
+ // '#body' must never shadow the element whose id is 'body'.
4912
+ if (ref.startsWith('#')) {
4913
+ return query(ref);
4914
+ }
4915
+ // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
4916
+ // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
4917
+ const escaped = ref.replace(/["\\]/g, '\\$&');
4918
+ const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
4919
+ if (from) {
4920
+ let scope = from.parentElement?.closest(ENTITY_SCOPES);
4921
+ while (scope) {
4922
+ const element = queryScope(scope, nameSelector);
4923
+ if (element) {
4924
+ return element;
4925
+ }
4926
+ scope = scope.parentElement?.closest(ENTITY_SCOPES);
4927
+ }
4928
+ const app = from.parentElement?.closest('pc-app');
4929
+ if (app) {
4930
+ const element = queryScope(app, nameSelector);
4931
+ if (element) {
4932
+ return element;
4933
+ }
4934
+ }
4935
+ }
4936
+ return query(nameSelector);
4937
+ };
4938
+ /**
4939
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element
4940
+ * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
4941
+ * through the entity hierarchy first when `from` is supplied — or a document-wide `#` selector
4942
+ * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
4943
+ * element (or backing entity) is found.
4944
+ *
4945
+ * @param ref - The reference string to resolve.
4946
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
4947
+ * name lookup. Omitted, the name lookup is document-wide only.
4948
+ * @returns The resolved entity, or `null`.
4949
+ * @internal
4950
+ */
4951
+ const getEntity = (ref, from) => {
4952
+ return entityOf(findEntityElement(ref, from));
4953
+ };
4954
+ /**
4955
+ * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
4956
+ * have three different fixes: nothing matches (usually a typo), the matched element is not backing
4957
+ * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
4958
+ * later can work), or the matched element can never back one (the reference points at the wrong
4959
+ * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
4960
+ * element inherits from EntityBaseElement.
4961
+ *
4962
+ * @param element - The element the reference matched, or `null` when nothing did.
4963
+ * @returns The cause, phrased to follow `could not resolve ... -`.
4964
+ * @internal
4965
+ */
4966
+ const unresolvedCause = (element) => {
4967
+ if (!element) {
4968
+ return 'nothing in the document matches it';
4969
+ }
4970
+ const tag = `<${element.tagName.toLowerCase()}>`;
4971
+ return 'entity' in element
4972
+ ? `${tag} matches it but is not backing an entity yet`
4973
+ : `${tag} matches it but cannot back an entity`;
4974
+ };
4975
+ /**
4976
+ * Builds the migration pointer for a bare reference that names nothing but matches the id of an
4977
+ * entity-fronting element - it was almost certainly meant as an id, so point at the form that
4978
+ * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
4979
+ * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
4980
+ * belongs to an element that could never back an entity - suggesting it would only trade this
4981
+ * warning for the wrong-target one.
4982
+ *
4983
+ * @param ref - The unresolved reference.
4984
+ * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
4985
+ * @returns The advice sentence, or an empty string.
4986
+ * @internal
4987
+ */
4988
+ const idHint = (ref, prefix = '') => {
4989
+ const match = !ref.startsWith('#') && document.getElementById(ref);
4990
+ return match && 'entity' in match
4991
+ ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
4992
+ : '';
4993
+ };
4994
+ /**
4995
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
4996
+ * the resolving element ({@link findEntityElement} details the order) and warning when a
4997
+ * non-empty reference does not resolve - otherwise the reference fails silently, invisible
4998
+ * except through the behavior it should have driven. The message names which of the three causes
4999
+ * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
5000
+ *
5001
+ * An empty reference stays silent: it is the unset state of an optional attribute, and on some
5002
+ * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
5003
+ *
5004
+ * @param ref - The reference string to resolve.
5005
+ * @param from - The element resolving the reference; scopes the lookup and names the message.
5006
+ * @param attribute - The attribute being resolved, for the message.
5007
+ * @param consequence - What the unresolved reference means for the element, for the message.
5008
+ * @returns The resolved entity, or `null`.
5009
+ * @internal
5010
+ */
5011
+ const resolveEntity = (ref, from, attribute, consequence) => {
5012
+ if (!ref) {
5013
+ return null;
5014
+ }
5015
+ const element = findEntityElement(ref, from);
5016
+ const entity = entityOf(element);
5017
+ if (!entity) {
5018
+ let advice = `Assign ${attribute} again once the entity exists.`;
5019
+ if (element && !('entity' in element)) {
5020
+ advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
5021
+ }
5022
+ else if (!element) {
5023
+ const hint = idHint(ref);
5024
+ if (hint) {
5025
+ advice = hint;
5026
+ }
5027
+ }
5028
+ console.warn(`${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`);
5029
+ }
5030
+ return entity;
5031
+ };
5032
+
4668
5033
  const transitionModes = new Map([
4669
5034
  ['tint', BUTTON_TRANSITION_MODE_TINT],
4670
5035
  ['sprite', BUTTON_TRANSITION_MODE_SPRITE_CHANGE]
@@ -4718,7 +5083,9 @@ class ButtonComponentElement extends ComponentElement {
4718
5083
  };
4719
5084
  // The image entity defaults to the button's own entity (which carries the image element)
4720
5085
  // when no explicit reference is provided.
4721
- const imageEntity = this._image ? getEntity(this._image) : this.closestEntity?.entity;
5086
+ const imageEntity = this._image
5087
+ ? resolveEntity(this._image, this, 'image', 'reference ignored')
5088
+ : this.closestEntity?.entity;
4722
5089
  if (imageEntity) {
4723
5090
  data.imageEntity = imageEntity;
4724
5091
  }
@@ -4737,8 +5104,9 @@ class ButtonComponentElement extends ComponentElement {
4737
5104
  return data;
4738
5105
  }
4739
5106
  /**
4740
- * Gets the underlying PlayCanvas button component.
4741
- * @returns The button component.
5107
+ * Gets the underlying PlayCanvas button component. `null` until the element is
5108
+ * ready see {@link ComponentElement.component}.
5109
+ * @returns The button component, or `null`.
4742
5110
  */
4743
5111
  get component() {
4744
5112
  return super.component;
@@ -4761,21 +5129,27 @@ class ButtonComponentElement extends ComponentElement {
4761
5129
  return this._active;
4762
5130
  }
4763
5131
  /**
4764
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` whose image
4765
- * element is used for visual transitions. Defaults to the button's own entity — inside a
4766
- * `<pc-model>`, that is the model's host entity, so supply an explicit reference to target a
4767
- * UI entity instead.
5132
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5133
+ * selector) to the entity whose image element is used for visual transitions. An exact name
5134
+ * resolves against the nearest enclosing entity first, then outward, then the document.
5135
+ * Defaults to the button's own entity — inside a `<pc-model>`, that is the model's host
5136
+ * entity, so supply an explicit reference to target a UI entity instead. A non-empty
5137
+ * reference that does not resolve warns and is ignored.
4768
5138
  * @param value - The image entity reference.
4769
5139
  */
4770
5140
  set image(value) {
4771
5141
  this._image = value;
4772
- const entity = getEntity(value);
4773
- if (this.component && entity) {
4774
- this.component.imageEntity = entity;
5142
+ if (this.component) {
5143
+ const entity = resolveEntity(value, this, 'image', 'reference ignored');
5144
+ if (entity) {
5145
+ this.component.imageEntity = entity;
5146
+ }
4775
5147
  }
4776
5148
  }
4777
5149
  /**
4778
- * Gets the reference to the `<pc-entity>` whose image element is used for visual transitions.
5150
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
5151
+ * selector) to the entity whose image element is used for visual transitions, or empty for
5152
+ * the button's own entity.
4779
5153
  * @returns The image entity reference.
4780
5154
  */
4781
5155
  get image() {
@@ -5190,8 +5564,9 @@ class CameraComponentElement extends ComponentElement {
5190
5564
  }
5191
5565
  }
5192
5566
  /**
5193
- * Gets the underlying PlayCanvas camera component.
5194
- * @returns The camera component.
5567
+ * Gets the underlying PlayCanvas camera component. `null` until the element is
5568
+ * ready see {@link ComponentElement.component}.
5569
+ * @returns The camera component, or `null`.
5195
5570
  */
5196
5571
  get component() {
5197
5572
  return super.component;
@@ -5680,8 +6055,9 @@ class CollisionComponentElement extends ComponentElement {
5680
6055
  component.renderAsset = asset;
5681
6056
  }
5682
6057
  /**
5683
- * Gets the underlying PlayCanvas collision component.
5684
- * @returns The collision component.
6058
+ * Gets the underlying PlayCanvas collision component. `null` until the element is
6059
+ * ready see {@link ComponentElement.component}.
6060
+ * @returns The collision component, or `null`.
5685
6061
  */
5686
6062
  get component() {
5687
6063
  return super.component;
@@ -5919,8 +6295,9 @@ class ElementComponentElement extends ComponentElement {
5919
6295
  return data;
5920
6296
  }
5921
6297
  /**
5922
- * Gets the underlying PlayCanvas element component.
5923
- * @returns The element component.
6298
+ * Gets the underlying PlayCanvas element component. `null` until the element is
6299
+ * ready see {@link ComponentElement.component}.
6300
+ * @returns The element component, or `null`.
5924
6301
  */
5925
6302
  get component() {
5926
6303
  return super.component;
@@ -6503,7 +6880,12 @@ customElements.define('pc-element', ElementComponentElement);
6503
6880
  * primary axis: a hinge rotates about it, a slider translates along it and a ball joint twists
6504
6881
  * about it. The constrained bodies are referenced by `entity-a` and `entity-b`, both of which need
6505
6882
  * a rigid body component; leaving `entity-b` empty constrains `entity-a` to a fixed point in world
6506
- * space. The underlying engine component is in alpha, so its API may change.
6883
+ * space. A reference can name any entity-fronting element `<pc-entity>`, `<pc-model>` or
6884
+ * `<pc-node>`, so a ragdoll can join a model's own skeleton nodes by name — and a name resolves
6885
+ * against the nearest enclosing entity first, then outward through the entity hierarchy, then the
6886
+ * document, while a `#` selector resolves document-wide. A `<template>` prefab with one
6887
+ * entity-fronting root can therefore wire its joints by name and stay self-contained when cloned.
6888
+ * The underlying engine component is in alpha, so its API may change.
6507
6889
  *
6508
6890
  * @elementSummary The `<pc-joint>` element constrains two rigid bodies to each other — a hinged
6509
6891
  * door, a swinging chain, a sliding drawer. Its entity's transform is the joint frame, and
@@ -6657,8 +7039,8 @@ class JointComponentElement extends ComponentElement {
6657
7039
  breakImpulse: this._breakImpulse,
6658
7040
  enableCollision: this._enableCollision,
6659
7041
  enableLimits: this._enableLimits,
6660
- entityA: getEntity(this._entityA),
6661
- entityB: getEntity(this._entityB),
7042
+ entityA: resolveEntity(this._entityA, this, 'entity-a', 'constraint not created'),
7043
+ entityB: resolveEntity(this._entityB, this, 'entity-b', 'constraint not created'),
6662
7044
  limits: this._limits,
6663
7045
  linearDamping: this._linearDamping,
6664
7046
  linearEquilibrium: this._linearEquilibrium,
@@ -6692,8 +7074,9 @@ class JointComponentElement extends ComponentElement {
6692
7074
  component.on('break', this._onBreak, this);
6693
7075
  }
6694
7076
  /**
6695
- * Gets the underlying PlayCanvas joint component.
6696
- * @returns The joint component.
7077
+ * Gets the underlying PlayCanvas joint component. `null` until the element is
7078
+ * ready see {@link ComponentElement.component}.
7079
+ * @returns The joint component, or `null`.
6697
7080
  */
6698
7081
  get component() {
6699
7082
  return super.component;
@@ -6913,39 +7296,48 @@ class JointComponentElement extends ComponentElement {
6913
7296
  return this._enableLimits;
6914
7297
  }
6915
7298
  /**
6916
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6917
- * the first constrained body. The reference resolves when it is set, so an entity created
6918
- * later is picked up by setting the attribute again.
7299
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7300
+ * selector) to the element providing the first constrained body. An exact name resolves
7301
+ * against the nearest enclosing entity first, then outward, then the document. The reference
7302
+ * resolves when it is set, so an entity created later is picked up by setting the attribute
7303
+ * again. A non-empty reference that does not resolve warns, naming which of the two causes it
7304
+ * hit.
6919
7305
  * @param value - The first body's entity reference.
6920
7306
  */
6921
7307
  set entityA(value) {
6922
7308
  this._entityA = value;
6923
7309
  if (this.component) {
6924
- this.component.entityA = getEntity(value);
7310
+ this.component.entityA = resolveEntity(value, this, 'entity-a', 'constraint not created');
6925
7311
  }
6926
7312
  }
6927
7313
  /**
6928
- * Gets the reference to the `<pc-entity>` providing the first constrained body.
7314
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7315
+ * selector) to the element providing the first constrained body.
6929
7316
  * @returns The first body's entity reference.
6930
7317
  */
6931
7318
  get entityA() {
6932
7319
  return this._entityA;
6933
7320
  }
6934
7321
  /**
6935
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
6936
- * the second constrained body, or empty to constrain the first body to a fixed point in world
6937
- * space. The reference resolves when it is set, so an entity created later is picked up by
6938
- * setting the attribute again.
7322
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7323
+ * selector) to the element providing the second constrained body, or empty to constrain the
7324
+ * first body to a fixed point in world space. An exact name resolves against the nearest
7325
+ * enclosing entity first, then outward, then the document. The reference resolves when it is
7326
+ * set, so an entity created later is picked up by setting the attribute again. A non-empty
7327
+ * reference that does not resolve warns; an empty one is the documented world-space case and
7328
+ * stays silent.
6939
7329
  * @param value - The second body's entity reference.
6940
7330
  */
6941
7331
  set entityB(value) {
6942
7332
  this._entityB = value;
6943
7333
  if (this.component) {
6944
- this.component.entityB = getEntity(value);
7334
+ this.component.entityB = resolveEntity(value, this, 'entity-b', 'constraint not created');
6945
7335
  }
6946
7336
  }
6947
7337
  /**
6948
- * Gets the reference to the `<pc-entity>` providing the second constrained body.
7338
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
7339
+ * selector) to the element providing the second constrained body, or empty for the
7340
+ * world-space case.
6949
7341
  * @returns The second body's entity reference.
6950
7342
  */
6951
7343
  get entityB() {
@@ -7410,8 +7802,9 @@ class LayoutChildComponentElement extends ComponentElement {
7410
7802
  };
7411
7803
  }
7412
7804
  /**
7413
- * Gets the underlying PlayCanvas layout child component.
7414
- * @returns The layout child component.
7805
+ * Gets the underlying PlayCanvas layout child component. `null` until the element is
7806
+ * ready see {@link ComponentElement.component}.
7807
+ * @returns The layout child component, or `null`.
7415
7808
  */
7416
7809
  get component() {
7417
7810
  return super.component;
@@ -7630,8 +8023,9 @@ class LayoutGroupComponentElement extends ComponentElement {
7630
8023
  };
7631
8024
  }
7632
8025
  /**
7633
- * Gets the underlying PlayCanvas layout group component.
7634
- * @returns The layout group component.
8026
+ * Gets the underlying PlayCanvas layout group component. `null` until the element is
8027
+ * ready see {@link ComponentElement.component}.
8028
+ * @returns The layout group component, or `null`.
7635
8029
  */
7636
8030
  get component() {
7637
8031
  return super.component;
@@ -7920,8 +8314,9 @@ class LightComponentElement extends ComponentElement {
7920
8314
  };
7921
8315
  }
7922
8316
  /**
7923
- * Gets the underlying PlayCanvas light component.
7924
- * @returns The light component.
8317
+ * Gets the underlying PlayCanvas light component. `null` until the element is
8318
+ * ready see {@link ComponentElement.component}.
8319
+ * @returns The light component, or `null`.
7925
8320
  */
7926
8321
  get component() {
7927
8322
  return super.component;
@@ -8437,36 +8832,68 @@ customElements.define('pc-light', LightComponentElement);
8437
8832
  */
8438
8833
  class ParticleSystemComponentElement extends ComponentElement {
8439
8834
  _asset = '';
8835
+ /**
8836
+ * The subscription to the current config asset while its load is in flight. Rebinding
8837
+ * supersedes it and disconnect cancels it, so a superseded config — an earlier asset that
8838
+ * finishes loading after its replacement, or a callback left behind by a previous
8839
+ * connection — can never configure the component.
8840
+ */
8841
+ _binding = new AssetBinding();
8440
8842
  /** @ignore */
8441
8843
  constructor() {
8442
8844
  super('particlesystem');
8443
8845
  }
8444
8846
  getInitialComponentData() {
8445
8847
  const asset = useAsset(this._asset);
8446
- // A lazy config has no resource yet - _loadAsset applies it once the load completes
8848
+ // A lazy config has no resource yet - the config binding applies it once the load
8849
+ // completes
8447
8850
  if (!asset || !asset.resource) {
8448
8851
  return {};
8449
8852
  }
8450
- if (asset.resource.colorMapAsset) {
8451
- const id = asset.resource.colorMapAsset;
8452
- const colorMapAsset = useAsset(id)?.id;
8453
- if (colorMapAsset) {
8454
- asset.resource.colorMapAsset = colorMapAsset;
8455
- }
8456
- }
8853
+ this._resolveColorMap(asset.resource);
8457
8854
  return asset.resource;
8458
8855
  }
8856
+ initComponent() {
8857
+ // A loaded config already arrived through getInitialComponentData - the binding is only
8858
+ // needed for a load still in flight. Resolution here also starts a lazy config's load.
8859
+ const asset = useAsset(this._asset);
8860
+ if (asset && !asset.loaded) {
8861
+ this._bindConfig();
8862
+ }
8863
+ }
8864
+ disconnectedCallback() {
8865
+ // The binding dies with the connection, so a config that finishes loading later cannot
8866
+ // configure the component a reconnection creates - that connection binds afresh.
8867
+ this._binding.cancel();
8868
+ super.disconnectedCallback();
8869
+ }
8459
8870
  /**
8460
- * Gets the underlying PlayCanvas particle system component.
8461
- * @returns The particle system component.
8871
+ * Gets the underlying PlayCanvas particle system component. `null` until the element is
8872
+ * ready see {@link ComponentElement.component}.
8873
+ * @returns The particle system component, or `null`.
8462
8874
  */
8463
8875
  get component() {
8464
8876
  return super.component;
8465
8877
  }
8878
+ /**
8879
+ * Rewrites the config's `colorMapAsset` from the `pc-asset` id it is authored with to the
8880
+ * engine asset id the component resolves, starting the texture's load if it is lazy. The
8881
+ * rewrite is in place, so a config applied again — a host cycle, a reconnection — is already
8882
+ * resolved and passes through unchanged.
8883
+ */
8884
+ _resolveColorMap(resource) {
8885
+ if (resource.colorMapAsset) {
8886
+ const colorMapAsset = useAsset(resource.colorMapAsset)?.id;
8887
+ if (colorMapAsset) {
8888
+ resource.colorMapAsset = colorMapAsset;
8889
+ }
8890
+ }
8891
+ }
8466
8892
  applyConfig(resource) {
8467
8893
  if (!this.component) {
8468
8894
  return;
8469
8895
  }
8896
+ this._resolveColorMap(resource);
8470
8897
  // Set all the config properties on the component
8471
8898
  for (const key in resource) {
8472
8899
  if (Object.hasOwn(resource, key)) {
@@ -8474,20 +8901,10 @@ class ParticleSystemComponentElement extends ComponentElement {
8474
8901
  }
8475
8902
  }
8476
8903
  }
8477
- async _loadAsset() {
8478
- await this.closestApp?.ready();
8479
- const asset = useAsset(this._asset);
8480
- if (!asset) {
8481
- return;
8482
- }
8483
- if (asset.loaded) {
8484
- this.applyConfig(asset.resource);
8485
- }
8486
- else {
8487
- asset.once('load', () => {
8488
- this.applyConfig(asset.resource);
8489
- });
8490
- }
8904
+ _bindConfig() {
8905
+ this._binding.bind(this._asset, {
8906
+ load: (asset) => this.applyConfig(asset.resource)
8907
+ });
8491
8908
  }
8492
8909
  /**
8493
8910
  * Sets the id of the `pc-asset` to use for the model.
@@ -8496,7 +8913,7 @@ class ParticleSystemComponentElement extends ComponentElement {
8496
8913
  set asset(value) {
8497
8914
  this._asset = value;
8498
8915
  if (this.isConnected) {
8499
- this._loadAsset();
8916
+ this._bindConfig();
8500
8917
  }
8501
8918
  }
8502
8919
  /**
@@ -8713,11 +9130,12 @@ class MaterialElement extends HTMLElement {
8713
9130
  _useSkybox = true;
8714
9131
  _useTonemap = true;
8715
9132
  /**
8716
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
8717
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
8718
- * element no longer wants.
9133
+ * One asset binding per texture slot, created on first use and kept for the element's
9134
+ * lifetime. A slot's binding is superseded when the slot is reassigned and cancelled when the
9135
+ * element disconnects, so a late-arriving asset can never write a texture the element no
9136
+ * longer wants.
8719
9137
  */
8720
- _mapHandles = new Map();
9138
+ _mapBindings = new Map();
8721
9139
  _updateScheduled = false;
8722
9140
  _glossConflictWarned = false;
8723
9141
  /**
@@ -8844,10 +9262,9 @@ class MaterialElement extends HTMLElement {
8844
9262
  material.update();
8845
9263
  }
8846
9264
  disconnectedCallback() {
8847
- for (const handle of this._mapHandles.values()) {
8848
- handle.off();
9265
+ for (const binding of this._mapBindings.values()) {
9266
+ binding.cancel();
8849
9267
  }
8850
- this._mapHandles.clear();
8851
9268
  if (this.material) {
8852
9269
  this.material.destroy();
8853
9270
  this.material = null;
@@ -8893,16 +9310,23 @@ class MaterialElement extends HTMLElement {
8893
9310
  'the roughness-* attributes invert gloss, so the two families contradict each other. Use one or the other.');
8894
9311
  }
8895
9312
  /**
8896
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
8897
- * has not already. An empty id clears the slot.
9313
+ * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when
9314
+ * it has not already. An empty id clears the slot; a slot keeps its current texture while
9315
+ * the new asset loads, and also across a failed load - a later reload can still deliver.
8898
9316
  *
8899
9317
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
8900
9318
  * @param slot - The material property to write.
8901
9319
  */
8902
9320
  _setMap(id, slot) {
8903
- // Drop any load still pending for this slot - its texture is no longer the one we want
8904
- this._mapHandles.get(slot)?.off();
8905
- this._mapHandles.delete(slot);
9321
+ let binding = this._mapBindings.get(slot);
9322
+ if (!binding) {
9323
+ binding = new AssetBinding();
9324
+ this._mapBindings.set(slot, binding);
9325
+ }
9326
+ // Drop any load still pending for this slot - its texture is no longer the one we want.
9327
+ // Cancelled here rather than left to the bind below, which the material-less and
9328
+ // clear-slot returns never reach.
9329
+ binding.cancel();
8906
9330
  if (!this.material)
8907
9331
  return;
8908
9332
  if (!id) {
@@ -8910,17 +9334,9 @@ class MaterialElement extends HTMLElement {
8910
9334
  this._scheduleUpdate();
8911
9335
  return;
8912
9336
  }
8913
- const asset = useAsset(id);
8914
- if (!asset)
8915
- return;
8916
- if (asset.loaded) {
8917
- this._applyMap(slot, asset.resource);
8918
- return;
8919
- }
8920
- this._mapHandles.set(slot, asset.once('load', () => {
8921
- this._mapHandles.delete(slot);
8922
- this._applyMap(slot, asset.resource);
8923
- }));
9337
+ binding.bind(id, {
9338
+ load: (asset) => this._applyMap(slot, asset.resource)
9339
+ });
8924
9340
  }
8925
9341
  /**
8926
9342
  * @param slot - The material property to write.
@@ -10816,8 +11232,9 @@ class RenderComponentElement extends ComponentElement {
10816
11232
  };
10817
11233
  }
10818
11234
  /**
10819
- * Gets the underlying PlayCanvas render component.
10820
- * @returns The render component.
11235
+ * Gets the underlying PlayCanvas render component. `null` until the element is
11236
+ * ready see {@link ComponentElement.component}.
11237
+ * @returns The render component, or `null`.
10821
11238
  */
10822
11239
  get component() {
10823
11240
  return super.component;
@@ -10988,8 +11405,9 @@ class RigidBodyComponentElement extends ComponentElement {
10988
11405
  };
10989
11406
  }
10990
11407
  /**
10991
- * Gets the underlying PlayCanvas rigidbody component.
10992
- * @returns The rigidbody component.
11408
+ * Gets the underlying PlayCanvas rigidbody component. `null` until the element is
11409
+ * ready see {@link ComponentElement.component}.
11410
+ * @returns The rigidbody component, or `null`.
10993
11411
  */
10994
11412
  get component() {
10995
11413
  return super.component;
@@ -11168,8 +11586,9 @@ class ScreenComponentElement extends ComponentElement {
11168
11586
  };
11169
11587
  }
11170
11588
  /**
11171
- * Gets the underlying PlayCanvas screen component.
11172
- * @returns The screen component.
11589
+ * Gets the underlying PlayCanvas screen component. `null` until the element is
11590
+ * ready see {@link ComponentElement.component}.
11591
+ * @returns The screen component, or `null`.
11173
11592
  */
11174
11593
  get component() {
11175
11594
  return super.component;
@@ -11319,15 +11738,16 @@ class ScrollbarComponentElement extends ComponentElement {
11319
11738
  value: this._value,
11320
11739
  handleSize: this._handleSize
11321
11740
  };
11322
- const handle = getEntity(this._handle);
11741
+ const handle = resolveEntity(this._handle, this, 'handle', 'reference ignored');
11323
11742
  if (handle) {
11324
11743
  data.handleEntity = handle;
11325
11744
  }
11326
11745
  return data;
11327
11746
  }
11328
11747
  /**
11329
- * Gets the underlying PlayCanvas scrollbar component.
11330
- * @returns The scrollbar component.
11748
+ * Gets the underlying PlayCanvas scrollbar component. `null` until the element is
11749
+ * ready see {@link ComponentElement.component}.
11750
+ * @returns The scrollbar component, or `null`.
11331
11751
  */
11332
11752
  get component() {
11333
11753
  return super.component;
@@ -11385,19 +11805,24 @@ class ScrollbarComponentElement extends ComponentElement {
11385
11805
  return this._handleSize;
11386
11806
  }
11387
11807
  /**
11388
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11389
- * scrollbar handle.
11808
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11809
+ * selector) to the entity used as the scrollbar handle. An exact name resolves against the
11810
+ * nearest enclosing entity first, then outward, then the document. A non-empty reference that
11811
+ * does not resolve warns and is ignored.
11390
11812
  * @param value - The handle entity reference.
11391
11813
  */
11392
11814
  set handle(value) {
11393
11815
  this._handle = value;
11394
- const entity = getEntity(value);
11395
- if (this.component && entity) {
11396
- this.component.handleEntity = entity;
11816
+ if (this.component) {
11817
+ const entity = resolveEntity(value, this, 'handle', 'reference ignored');
11818
+ if (entity) {
11819
+ this.component.handleEntity = entity;
11820
+ }
11397
11821
  }
11398
11822
  }
11399
11823
  /**
11400
- * Gets the reference to the `<pc-entity>` used as the scrollbar handle.
11824
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
11825
+ * selector) to the entity used as the scrollbar handle.
11401
11826
  * @returns The handle entity reference.
11402
11827
  */
11403
11828
  get handle() {
@@ -11479,27 +11904,28 @@ class ScrollViewComponentElement extends ComponentElement {
11479
11904
  horizontalScrollbarVisibility: visibilities.get(this._horizontalScrollbarVisibility),
11480
11905
  verticalScrollbarVisibility: visibilities.get(this._verticalScrollbarVisibility)
11481
11906
  };
11482
- const viewport = getEntity(this._viewport);
11907
+ const viewport = resolveEntity(this._viewport, this, 'viewport', 'reference ignored');
11483
11908
  if (viewport) {
11484
11909
  data.viewportEntity = viewport;
11485
11910
  }
11486
- const content = getEntity(this._content);
11911
+ const content = resolveEntity(this._content, this, 'content', 'reference ignored');
11487
11912
  if (content) {
11488
11913
  data.contentEntity = content;
11489
11914
  }
11490
- const horizontalScrollbar = getEntity(this._horizontalScrollbar);
11915
+ const horizontalScrollbar = resolveEntity(this._horizontalScrollbar, this, 'horizontal-scrollbar', 'reference ignored');
11491
11916
  if (horizontalScrollbar) {
11492
11917
  data.horizontalScrollbarEntity = horizontalScrollbar;
11493
11918
  }
11494
- const verticalScrollbar = getEntity(this._verticalScrollbar);
11919
+ const verticalScrollbar = resolveEntity(this._verticalScrollbar, this, 'vertical-scrollbar', 'reference ignored');
11495
11920
  if (verticalScrollbar) {
11496
11921
  data.verticalScrollbarEntity = verticalScrollbar;
11497
11922
  }
11498
11923
  return data;
11499
11924
  }
11500
11925
  /**
11501
- * Gets the underlying PlayCanvas scroll view component.
11502
- * @returns The scroll view component.
11926
+ * Gets the underlying PlayCanvas scroll view component. `null` until the element is
11927
+ * ready see {@link ComponentElement.component}.
11928
+ * @returns The scroll view component, or `null`.
11503
11929
  */
11504
11930
  get component() {
11505
11931
  return super.component;
@@ -11667,76 +12093,96 @@ class ScrollViewComponentElement extends ComponentElement {
11667
12093
  return this._verticalScrollbarVisibility;
11668
12094
  }
11669
12095
  /**
11670
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11671
- * viewport, which clips the content to the scroll view's bounds.
12096
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12097
+ * selector) to the entity used as the viewport, which clips the content to the scroll view's
12098
+ * bounds. An exact name resolves against the nearest enclosing entity first, then outward,
12099
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11672
12100
  * @param value - The viewport entity reference.
11673
12101
  */
11674
12102
  set viewport(value) {
11675
12103
  this._viewport = value;
11676
- const entity = getEntity(value);
11677
- if (this.component && entity) {
11678
- this.component.viewportEntity = entity;
12104
+ if (this.component) {
12105
+ const entity = resolveEntity(value, this, 'viewport', 'reference ignored');
12106
+ if (entity) {
12107
+ this.component.viewportEntity = entity;
12108
+ }
11679
12109
  }
11680
12110
  }
11681
12111
  /**
11682
- * Gets the reference to the `<pc-entity>` used as the viewport.
12112
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12113
+ * selector) to the entity used as the viewport.
11683
12114
  * @returns The viewport entity reference.
11684
12115
  */
11685
12116
  get viewport() {
11686
12117
  return this._viewport;
11687
12118
  }
11688
12119
  /**
11689
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` used as the
11690
- * content, which is moved as the scroll view is scrolled.
12120
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12121
+ * selector) to the entity used as the content, which is moved as the scroll view is
12122
+ * scrolled. An exact name resolves against the nearest enclosing entity first, then outward,
12123
+ * then the document. A non-empty reference that does not resolve warns and is ignored.
11691
12124
  * @param value - The content entity reference.
11692
12125
  */
11693
12126
  set content(value) {
11694
12127
  this._content = value;
11695
- const entity = getEntity(value);
11696
- if (this.component && entity) {
11697
- this.component.contentEntity = entity;
12128
+ if (this.component) {
12129
+ const entity = resolveEntity(value, this, 'content', 'reference ignored');
12130
+ if (entity) {
12131
+ this.component.contentEntity = entity;
12132
+ }
11698
12133
  }
11699
12134
  }
11700
12135
  /**
11701
- * Gets the reference to the `<pc-entity>` used as the content.
12136
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12137
+ * selector) to the entity used as the content.
11702
12138
  * @returns The content entity reference.
11703
12139
  */
11704
12140
  get content() {
11705
12141
  return this._content;
11706
12142
  }
11707
12143
  /**
11708
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11709
- * the horizontal `<pc-scrollbar>`.
12144
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12145
+ * selector) to the entity containing the horizontal `<pc-scrollbar>`. An exact name resolves
12146
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12147
+ * reference that does not resolve warns and is ignored.
11710
12148
  * @param value - The horizontal scrollbar entity reference.
11711
12149
  */
11712
12150
  set horizontalScrollbar(value) {
11713
12151
  this._horizontalScrollbar = value;
11714
- const entity = getEntity(value);
11715
- if (this.component && entity) {
11716
- this.component.horizontalScrollbarEntity = entity;
12152
+ if (this.component) {
12153
+ const entity = resolveEntity(value, this, 'horizontal-scrollbar', 'reference ignored');
12154
+ if (entity) {
12155
+ this.component.horizontalScrollbarEntity = entity;
12156
+ }
11717
12157
  }
11718
12158
  }
11719
12159
  /**
11720
- * Gets the reference to the `<pc-entity>` containing the horizontal scrollbar.
12160
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12161
+ * selector) to the entity containing the horizontal scrollbar.
11721
12162
  * @returns The horizontal scrollbar entity reference.
11722
12163
  */
11723
12164
  get horizontalScrollbar() {
11724
12165
  return this._horizontalScrollbar;
11725
12166
  }
11726
12167
  /**
11727
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` containing
11728
- * the vertical `<pc-scrollbar>`.
12168
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12169
+ * selector) to the entity containing the vertical `<pc-scrollbar>`. An exact name resolves
12170
+ * against the nearest enclosing entity first, then outward, then the document. A non-empty
12171
+ * reference that does not resolve warns and is ignored.
11729
12172
  * @param value - The vertical scrollbar entity reference.
11730
12173
  */
11731
12174
  set verticalScrollbar(value) {
11732
12175
  this._verticalScrollbar = value;
11733
- const entity = getEntity(value);
11734
- if (this.component && entity) {
11735
- this.component.verticalScrollbarEntity = entity;
12176
+ if (this.component) {
12177
+ const entity = resolveEntity(value, this, 'vertical-scrollbar', 'reference ignored');
12178
+ if (entity) {
12179
+ this.component.verticalScrollbarEntity = entity;
12180
+ }
11736
12181
  }
11737
12182
  }
11738
12183
  /**
11739
- * Gets the reference to the `<pc-entity>` containing the vertical scrollbar.
12184
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
12185
+ * selector) to the entity containing the vertical scrollbar.
11740
12186
  * @returns The vertical scrollbar entity reference.
11741
12187
  */
11742
12188
  get verticalScrollbar() {
@@ -11819,7 +12265,9 @@ customElements.define('pc-scroll-view', ScrollViewComponentElement);
11819
12265
  * Values are parsed according to the type of the attribute's current value — initially the
11820
12266
  * script's declared default (numbers, booleans, strings, Vec2/3/4, Color, Quat as Euler
11821
12267
  * angles) — and the `asset:`/`entity:`/`vec2:`/`vec3:`/`vec4:`/`color:` prefixes may be used
11822
- * to be explicit.
12268
+ * to be explicit. An `entity:` reference is an entity name — resolved against the nearest
12269
+ * enclosing entity first, then outward, then the document — or a document-wide `#` selector
12270
+ * (`entity:#id`); a bare value is always a name, never an element id.
11823
12271
  * - **The `attributes` JSON attribute**: an object supporting nested structures and attribute
11824
12272
  * names that collide with reserved HTML attribute names (e.g. `title`).
11825
12273
  *
@@ -11835,7 +12283,8 @@ customElements.define('pc-scroll-view', ScrollViewComponentElement);
11835
12283
  *
11836
12284
  * @elementSummary The `<pc-script-instance>` element attaches one script class, named by `name`, to
11837
12285
  * the entity of its parent `<pc-script>`. Its other attributes set script attributes of the same
11838
- * name, and `attributes` takes a JSON object instead. Must be a direct child of `<pc-script>`.
12286
+ * name, and `attributes` takes a JSON object instead. An `entity:` value is an entity name —
12287
+ * write `entity:#id` for an element id. Must be a direct child of `<pc-script>`.
11839
12288
  *
11840
12289
  * @fires {CustomEvent} scriptattributeschange - Fired when the script's attributes change. The
11841
12290
  * `detail` carries the new `attributes` object. Bubbles.
@@ -11855,9 +12304,11 @@ class ScriptInstanceElement extends AsyncElement {
11855
12304
  /**
11856
12305
  * Sets the attributes of the script as an object. Values are converted with the same rules
11857
12306
  * as the `attributes` attribute: `asset:`/`entity:` references and `vec2:`/`vec3:`/`vec4:`/
11858
- * `color:` prefixed strings are resolved, and a plain numeric array is converted to the
11859
- * type of the attribute it targets when that attribute currently holds a Vec2, Vec3, Vec4
11860
- * or Color.
12307
+ * `color:` prefixed strings are resolved (an entity name against the nearest enclosing
12308
+ * entity first, then outward, then the document or a document-wide `#` selector; a bare
12309
+ * value is always a name, never an element id), and a plain numeric array is converted to
12310
+ * the type of the attribute it targets when that attribute currently holds a Vec2, Vec3,
12311
+ * Vec4 or Color.
11861
12312
  * @param value - The attributes of the script.
11862
12313
  */
11863
12314
  set scriptAttributes(value) {
@@ -11868,7 +12319,10 @@ class ScriptInstanceElement extends AsyncElement {
11868
12319
  }));
11869
12320
  }
11870
12321
  /**
11871
- * Gets the attributes of the script.
12322
+ * Gets the attributes of the script as an object whose `asset:`, `entity:`, `vec2:`, `vec3:`,
12323
+ * `vec4:` and `color:` prefixed values are resolved when applied — an `entity:` value being
12324
+ * an entity name (nearest enclosing entity first, then outward, then the document) or a
12325
+ * document-wide `#` selector (`entity:#id`), never a bare element id.
11872
12326
  * @returns The attributes of the script.
11873
12327
  */
11874
12328
  get scriptAttributes() {
@@ -12079,18 +12533,23 @@ const assetConversion = (rest, raw) => {
12079
12533
  return raw;
12080
12534
  };
12081
12535
  /**
12082
- * Resolves an `entity:` prefix to the Entity backing a `pc-entity` element. The reference can be a
12083
- * CSS selector, an element id or an entity name.
12536
+ * Resolves an `entity:` prefix to the Entity backing a `pc-entity`, `pc-model` or `pc-node`
12537
+ * element. The reference is a name resolved against the nearest enclosing entity first, then
12538
+ * outward, then the document — or a document-wide `#` selector. The failure warning names which
12539
+ * of the three causes ({@link unresolvedCause}) it hit.
12084
12540
  * @param rest - The entity reference.
12085
12541
  * @param raw - The raw value, returned unchanged when the reference does not resolve.
12542
+ * @param from - The element the value is declared under, which scopes the reference.
12086
12543
  * @returns The entity, or `raw`.
12087
12544
  */
12088
- const entityConversion = (rest, raw) => {
12089
- const entity = getEntity(rest);
12545
+ const entityConversion = (rest, raw, from) => {
12546
+ const entity = getEntity(rest, from);
12090
12547
  if (entity) {
12091
12548
  return entity;
12092
12549
  }
12093
- console.warn(`Unable to resolve '${raw}' in script attributes - no pc-entity found matching '${rest}'.`);
12550
+ const element = findEntityElement(rest, from);
12551
+ const hint = element ? '' : idHint(rest, 'entity:');
12552
+ console.warn(`Unable to resolve '${raw}' in script attributes - ${unresolvedCause(element)}.${hint ? ` ${hint}` : ''}`);
12094
12553
  return raw;
12095
12554
  };
12096
12555
  /**
@@ -12223,8 +12682,10 @@ class ScriptComponentElement extends ComponentElement {
12223
12682
  /**
12224
12683
  * Recursively converts raw attribute data into proper PlayCanvas types. Supported conversions:
12225
12684
  * - "asset:id" → the Asset created by the `pc-asset` element with that id
12226
- * - "entity:ref" → the Entity backing a `pc-entity` element. The reference can be a CSS
12227
- * selector, an element id or an entity name.
12685
+ * - "entity:ref" → the Entity backing a `pc-entity`, `pc-model` or `pc-node` element. The
12686
+ * reference is a name, resolved against this element's nearest enclosing entity first,
12687
+ * then outward, then the document — or a document-wide `#` selector (`entity:#id`). A bare
12688
+ * value is always a name, never an id.
12228
12689
  * - "vec2:1 2" → new Vec2(1, 2)
12229
12690
  * - "vec3:1 2 3" → new Vec3(1, 2, 3)
12230
12691
  * - "vec4:1 2 3 4" → new Vec4(1, 2, 3, 4)
@@ -12238,7 +12699,7 @@ class ScriptComponentElement extends ComponentElement {
12238
12699
  convertAttributes(item) {
12239
12700
  if (typeof item === 'string') {
12240
12701
  const match = matchConversion(item);
12241
- return match ? match.convert(match.rest, item) : item;
12702
+ return match ? match.convert(match.rest, item, this) : item;
12242
12703
  }
12243
12704
  if (Array.isArray(item)) {
12244
12705
  return item.map((element) => this.convertAttributes(element));
@@ -12616,8 +13077,9 @@ class ScriptComponentElement extends ComponentElement {
12616
13077
  super.disconnectedCallback?.();
12617
13078
  }
12618
13079
  /**
12619
- * Gets the underlying PlayCanvas script component.
12620
- * @returns The script component.
13080
+ * Gets the underlying PlayCanvas script component. `null` until the element is
13081
+ * ready see {@link ComponentElement.component}.
13082
+ * @returns The script component, or `null`.
12621
13083
  */
12622
13084
  get component() {
12623
13085
  return super.component;
@@ -12663,8 +13125,9 @@ class SoundComponentElement extends ComponentElement {
12663
13125
  };
12664
13126
  }
12665
13127
  /**
12666
- * Gets the underlying PlayCanvas sound component.
12667
- * @returns The sound component.
13128
+ * Gets the underlying PlayCanvas sound component. `null` until the element is
13129
+ * ready see {@link ComponentElement.component}.
13130
+ * @returns The sound component, or `null`.
12668
13131
  */
12669
13132
  get component() {
12670
13133
  return super.component;
@@ -13145,8 +13608,9 @@ class GSplatComponentElement extends ComponentElement {
13145
13608
  };
13146
13609
  }
13147
13610
  /**
13148
- * Gets the underlying PlayCanvas gsplat component.
13149
- * @returns The gsplat component.
13611
+ * Gets the underlying PlayCanvas gsplat component. `null` until the element is
13612
+ * ready see {@link ComponentElement.component}.
13613
+ * @returns The gsplat component, or `null`.
13150
13614
  */
13151
13615
  get component() {
13152
13616
  return super.component;
@@ -14439,31 +14903,27 @@ class SkyElement extends AsyncElement {
14439
14903
  _appElement = null;
14440
14904
  /**
14441
14905
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
14442
- * load that resumes from an await or a load callback abandons itself if the value has moved
14443
- * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
14906
+ * load that resumes from an await abandons itself if the value has moved on, so a superseded
14907
+ * load cannot generate a skybox for a scene it no longer configures. The asset subscription
14908
+ * itself is guarded by the binding below.
14444
14909
  */
14445
14910
  _loadGeneration = 0;
14446
14911
  /**
14447
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
14448
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
14449
- * than leave it registered until the asset loads (or forever, if it never does).
14912
+ * Watches the current texture asset while it loads. Starting a new load or disconnecting
14913
+ * cancels it, so a superseded texture can never generate the skybox.
14450
14914
  */
14451
- _loadHandle = null;
14915
+ _binding = new AssetBinding();
14452
14916
  connectedCallback() {
14453
14917
  this._loadSkybox();
14454
14918
  this._onReady();
14455
14919
  }
14456
14920
  disconnectedCallback() {
14457
14921
  this._loadGeneration++;
14458
- this._detachLoadHandler();
14922
+ this._binding.cancel();
14459
14923
  this._unloadSkybox();
14460
14924
  this._appElement = null;
14461
14925
  this._resetReady();
14462
14926
  }
14463
- _detachLoadHandler() {
14464
- this._loadHandle?.off();
14465
- this._loadHandle = null;
14466
- }
14467
14927
  _generateSkybox(asset) {
14468
14928
  if (!this._scene)
14469
14929
  return;
@@ -14495,7 +14955,7 @@ class SkyElement extends AsyncElement {
14495
14955
  async _loadSkybox() {
14496
14956
  // Supersede any load already in flight - only the newest load may generate the skybox
14497
14957
  const generation = ++this._loadGeneration;
14498
- this._detachLoadHandler();
14958
+ this._binding.cancel();
14499
14959
  const appElement = await this.closestApp?.ready();
14500
14960
  // The element may have been removed, or another load started, while we waited
14501
14961
  if (generation !== this._loadGeneration) {
@@ -14506,26 +14966,18 @@ class SkyElement extends AsyncElement {
14506
14966
  return;
14507
14967
  }
14508
14968
  this._appElement = appElement;
14509
- const asset = useAsset(this._asset);
14510
- if (!asset) {
14969
+ // The scene is only adopted once the reference resolves: an unresolved id must leave the
14970
+ // scene untouched, or this element's teardown would destroy a skybox it never created.
14971
+ // The bind below repeats the resolution, which useAsset documents as free - it cannot
14972
+ // happen after the bind, because a loaded asset delivers before bind returns and
14973
+ // _generateSkybox needs the scene by then.
14974
+ if (!useAsset(this._asset)) {
14511
14975
  return;
14512
14976
  }
14513
14977
  this._scene = app.scene;
14514
- if (asset.loaded) {
14515
- this._generateSkybox(asset);
14516
- }
14517
- else {
14518
- // The generation is re-checked even though a superseded handler is detached: the
14519
- // detach relies on how the engine's event emitter treats removal, while the check
14520
- // holds on its own.
14521
- this._loadHandle = asset.once('load', () => {
14522
- this._loadHandle = null;
14523
- if (generation !== this._loadGeneration) {
14524
- return;
14525
- }
14526
- this._generateSkybox(asset);
14527
- });
14528
- }
14978
+ this._binding.bind(this._asset, {
14979
+ load: (asset) => this._generateSkybox(asset)
14980
+ });
14529
14981
  }
14530
14982
  _unloadSkybox() {
14531
14983
  const scene = this._scene;