@playcanvas/web-components 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/app.d.cts +5 -113
  2. package/dist/app.d.ts +5 -113
  3. package/dist/asset-binding.d.cts +1 -0
  4. package/dist/asset-binding.d.ts +1 -0
  5. package/dist/components/anim-clip.d.cts +8 -10
  6. package/dist/components/anim-clip.d.ts +8 -10
  7. package/dist/components/anim-component.d.cts +5 -4
  8. package/dist/components/anim-component.d.ts +5 -4
  9. package/dist/components/audio-listener-component.d.cts +5 -4
  10. package/dist/components/audio-listener-component.d.ts +5 -4
  11. package/dist/components/button-component.d.cts +5 -4
  12. package/dist/components/button-component.d.ts +5 -4
  13. package/dist/components/camera-component.d.cts +5 -4
  14. package/dist/components/camera-component.d.ts +5 -4
  15. package/dist/components/collision-component.d.cts +5 -4
  16. package/dist/components/collision-component.d.ts +5 -4
  17. package/dist/components/component.d.cts +2 -2
  18. package/dist/components/component.d.ts +2 -2
  19. package/dist/components/element-component.d.cts +5 -4
  20. package/dist/components/element-component.d.ts +5 -4
  21. package/dist/components/gsplat-component.d.cts +5 -4
  22. package/dist/components/gsplat-component.d.ts +5 -4
  23. package/dist/components/joint-component.d.cts +5 -4
  24. package/dist/components/joint-component.d.ts +5 -4
  25. package/dist/components/layout-child-component.d.cts +5 -4
  26. package/dist/components/layout-child-component.d.ts +5 -4
  27. package/dist/components/layout-group-component.d.cts +5 -4
  28. package/dist/components/layout-group-component.d.ts +5 -4
  29. package/dist/components/light-component.d.cts +5 -4
  30. package/dist/components/light-component.d.ts +5 -4
  31. package/dist/components/particle-system-component.d.cts +22 -5
  32. package/dist/components/particle-system-component.d.ts +22 -5
  33. package/dist/components/render-component.d.cts +5 -4
  34. package/dist/components/render-component.d.ts +5 -4
  35. package/dist/components/rigid-body-component.d.cts +5 -4
  36. package/dist/components/rigid-body-component.d.ts +5 -4
  37. package/dist/components/screen-component.d.cts +5 -4
  38. package/dist/components/screen-component.d.ts +5 -4
  39. package/dist/components/script-component.d.cts +5 -4
  40. package/dist/components/script-component.d.ts +5 -4
  41. package/dist/components/scroll-view-component.d.cts +5 -4
  42. package/dist/components/scroll-view-component.d.ts +5 -4
  43. package/dist/components/scrollbar-component.d.cts +5 -4
  44. package/dist/components/scrollbar-component.d.ts +5 -4
  45. package/dist/components/sound-component.d.cts +5 -4
  46. package/dist/components/sound-component.d.ts +5 -4
  47. package/dist/custom-elements.json +38 -38
  48. package/dist/entity-reference.d.cts +13 -0
  49. package/dist/entity-reference.d.ts +13 -0
  50. package/dist/material.d.cts +8 -6
  51. package/dist/material.d.ts +8 -6
  52. package/dist/model.d.cts +8 -9
  53. package/dist/model.d.ts +8 -9
  54. package/dist/parse.d.cts +0 -8
  55. package/dist/parse.d.ts +0 -8
  56. package/dist/pointer-controller.d.cts +1 -0
  57. package/dist/pointer-controller.d.ts +1 -0
  58. package/dist/pwc.cjs +1360 -1186
  59. package/dist/pwc.cjs.map +1 -1
  60. package/dist/pwc.js +1360 -1186
  61. package/dist/pwc.js.map +1 -1
  62. package/dist/pwc.min.js +1 -1
  63. package/dist/pwc.min.js.map +1 -1
  64. package/dist/pwc.min.mjs +1 -1
  65. package/dist/pwc.min.mjs.map +1 -1
  66. package/dist/pwc.mjs +1361 -1187
  67. package/dist/pwc.mjs.map +1 -1
  68. package/dist/sky.d.cts +6 -7
  69. package/dist/sky.d.ts +6 -7
  70. package/dist/web-types.json +39 -39
  71. package/package.json +2 -2
  72. package/src/app.ts +16 -451
  73. package/src/asset-binding.ts +141 -0
  74. package/src/components/anim-clip.ts +22 -45
  75. package/src/components/anim-component.ts +10 -6
  76. package/src/components/audio-listener-component.ts +6 -5
  77. package/src/components/button-component.ts +8 -6
  78. package/src/components/camera-component.ts +6 -5
  79. package/src/components/collision-component.ts +6 -5
  80. package/src/components/component.ts +6 -4
  81. package/src/components/element-component.ts +6 -5
  82. package/src/components/gsplat-component.ts +6 -5
  83. package/src/components/joint-component.ts +8 -6
  84. package/src/components/layout-child-component.ts +6 -5
  85. package/src/components/layout-group-component.ts +6 -5
  86. package/src/components/light-component.ts +6 -5
  87. package/src/components/particle-system-component.ts +56 -30
  88. package/src/components/render-component.ts +6 -5
  89. package/src/components/rigid-body-component.ts +6 -5
  90. package/src/components/screen-component.ts +6 -5
  91. package/src/components/script-component.ts +8 -10
  92. package/src/components/scroll-view-component.ts +8 -6
  93. package/src/components/scrollbar-component.ts +8 -6
  94. package/src/components/sound-component.ts +6 -5
  95. package/src/entity-reference.ts +237 -0
  96. package/src/material.ts +25 -29
  97. package/src/model.ts +24 -48
  98. package/src/parse.ts +0 -232
  99. package/src/pointer-controller.ts +550 -0
  100. package/src/sky.ts +19 -30
package/dist/pwc.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,14 +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
- * `findEntityElement` and `getEntity` are the exceptions: they resolve a reference rather than
589
- * parsing a literal, and return `null` instead of falling back to a default. A reference
590
- * beginning with `#` is a document-wide selector (an element id, or any selector rooted in one);
591
- * anything else is an entity name, resolved lexically through the entity hierarchy first and
592
- * against the document after — never as a selector or an id. They also do not warn - what an
593
- * unresolved reference means depends on the element holding it - so elements report through
594
- * `resolveEntity`, which takes that meaning as parameters.
595
587
  */
596
588
  /**
597
589
  * Splits an attribute value into exactly `count` numeric components. Returns `null` when the
@@ -837,215 +829,10 @@ const parseVec4 = (value, defaultValue, attribute) => {
837
829
  }
838
830
  return new Vec4(components);
839
831
  };
840
- /**
841
- * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
842
- * arbitrary author text, so a lookup must fail to `null`, never throw.
843
- *
844
- * @param selector - The selector to query.
845
- * @returns The matched element, or `null`.
846
- */
847
- const query = (selector) => {
848
- try {
849
- return document.querySelector(selector);
850
- }
851
- catch {
852
- return null;
853
- }
854
- };
855
- /**
856
- * Runs a lookup against one scope, checking the scope element itself before its subtree — a
857
- * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
858
- * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
859
- * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
860
- * lookup must fail to `null`, never throw.
861
- *
862
- * @param scope - The element whose inclusive subtree to search.
863
- * @param selector - The selector to query.
864
- * @returns The matched element, or `null`.
865
- */
866
- const queryScope = (scope, selector) => {
867
- try {
868
- return scope.matches(selector) ? scope : scope.querySelector(selector);
869
- }
870
- catch {
871
- return null;
872
- }
873
- };
874
- /**
875
- * Reads the entity a resolved element is backing, through the `entity` accessor every
876
- * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
877
- *
878
- * @param element - The element to read, or `null`.
879
- * @returns The backing entity, or `null`.
880
- */
881
- const entityOf = (element) => {
882
- return element?.entity ?? null;
883
- };
884
- /**
885
- * The elements that front an entity: what a bare name can resolve to, and the scopes of the
886
- * lexical name lookup.
887
- */
888
- const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'];
889
- /**
890
- * The entity-fronting elements as one selector, for the scope walk.
891
- */
892
- const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
893
- /**
894
- * Resolves a reference string to the element it names. The grammar is closed — every reference
895
- * has exactly one interpretation:
896
- *
897
- * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
898
- * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
899
- * runs for it, so an unusually named entity cannot shadow it.
900
- * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
901
- * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
902
- * never interpreted as a selector or an element id, so adding or renaming elements can never
903
- * change which form it takes.
904
- *
905
- * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
906
- * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
907
- * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
908
- * entities by name — every clone resolves within itself before a document-wide lookup could reach
909
- * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
910
- * scope.
911
- *
912
- * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
913
- * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
914
- * something matches but is not backing an entity (yet, or ever).
915
- *
916
- * @param ref - The reference string to resolve.
917
- * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
918
- * name lookup. Omitted, the name lookup is document-wide only.
919
- * @returns The matched element, or `null`.
920
- * @internal
921
- */
922
- const findEntityElement = (ref, from) => {
923
- if (!ref) {
924
- return null;
925
- }
926
- // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
927
- // '#body' must never shadow the element whose id is 'body'.
928
- if (ref.startsWith('#')) {
929
- return query(ref);
930
- }
931
- // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
932
- // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
933
- const escaped = ref.replace(/["\\]/g, '\\$&');
934
- const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
935
- if (from) {
936
- let scope = from.parentElement?.closest(ENTITY_SCOPES);
937
- while (scope) {
938
- const element = queryScope(scope, nameSelector);
939
- if (element) {
940
- return element;
941
- }
942
- scope = scope.parentElement?.closest(ENTITY_SCOPES);
943
- }
944
- const app = from.parentElement?.closest('pc-app');
945
- if (app) {
946
- const element = queryScope(app, nameSelector);
947
- if (element) {
948
- return element;
949
- }
950
- }
951
- }
952
- return query(nameSelector);
953
- };
954
- /**
955
- * Resolves a reference string to the {@link Entity} backing an entity-fronting element
956
- * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
957
- * through the entity hierarchy first when `from` is supplied — or a document-wide `#` selector
958
- * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
959
- * element (or backing entity) is found.
960
- *
961
- * @param ref - The reference string to resolve.
962
- * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
963
- * name lookup. Omitted, the name lookup is document-wide only.
964
- * @returns The resolved entity, or `null`.
965
- * @internal
966
- */
967
- const getEntity = (ref, from) => {
968
- return entityOf(findEntityElement(ref, from));
969
- };
970
- /**
971
- * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
972
- * have three different fixes: nothing matches (usually a typo), the matched element is not backing
973
- * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
974
- * later can work), or the matched element can never back one (the reference points at the wrong
975
- * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
976
- * element inherits from EntityBaseElement.
977
- *
978
- * @param element - The element the reference matched, or `null` when nothing did.
979
- * @returns The cause, phrased to follow `could not resolve ... -`.
980
- * @internal
981
- */
982
- const unresolvedCause = (element) => {
983
- if (!element) {
984
- return 'nothing in the document matches it';
985
- }
986
- const tag = `<${element.tagName.toLowerCase()}>`;
987
- return 'entity' in element
988
- ? `${tag} matches it but is not backing an entity yet`
989
- : `${tag} matches it but cannot back an entity`;
990
- };
991
- /**
992
- * Builds the migration pointer for a bare reference that names nothing but matches the id of an
993
- * entity-fronting element - it was almost certainly meant as an id, so point at the form that
994
- * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
995
- * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
996
- * belongs to an element that could never back an entity - suggesting it would only trade this
997
- * warning for the wrong-target one.
998
- *
999
- * @param ref - The unresolved reference.
1000
- * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
1001
- * @returns The advice sentence, or an empty string.
1002
- * @internal
1003
- */
1004
- const idHint = (ref, prefix = '') => {
1005
- const match = !ref.startsWith('#') && document.getElementById(ref);
1006
- return match && 'entity' in match
1007
- ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
1008
- : '';
1009
- };
1010
- /**
1011
- * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
1012
- * the resolving element ({@link findEntityElement} details the order) and warning when a
1013
- * non-empty reference does not resolve - otherwise the reference fails silently, invisible
1014
- * except through the behavior it should have driven. The message names which of the three causes
1015
- * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
1016
- *
1017
- * An empty reference stays silent: it is the unset state of an optional attribute, and on some
1018
- * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
1019
- *
1020
- * @param ref - The reference string to resolve.
1021
- * @param from - The element resolving the reference; scopes the lookup and names the message.
1022
- * @param attribute - The attribute being resolved, for the message.
1023
- * @param consequence - What the unresolved reference means for the element, for the message.
1024
- * @returns The resolved entity, or `null`.
1025
- * @internal
1026
- */
1027
- const resolveEntity = (ref, from, attribute, consequence) => {
1028
- if (!ref) {
1029
- return null;
1030
- }
1031
- const element = findEntityElement(ref, from);
1032
- const entity = entityOf(element);
1033
- if (!entity) {
1034
- let advice = `Assign ${attribute} again once the entity exists.`;
1035
- if (element && !('entity' in element)) {
1036
- advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
1037
- }
1038
- else if (!element) {
1039
- const hint = idHint(ref);
1040
- if (hint) {
1041
- advice = hint;
1042
- }
1043
- }
1044
- console.warn(`${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`);
1045
- }
1046
- return entity;
1047
- };
1048
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.
1049
836
  /**
1050
837
  * The event types whose listeners make an element a hover target. Hover resolution walks past
1051
838
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -1092,88 +879,29 @@ const commonAncestor = (a, b) => {
1092
879
  return null;
1093
880
  };
1094
881
  /**
1095
- * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
1096
- * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
1097
- * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
1098
- * so any page rule - however plain - overrides these defaults.
1099
- */
1100
- const ensureBaseStyles = () => {
1101
- const id = 'pc-app-styles';
1102
- if (document.getElementById(id)) {
1103
- return;
1104
- }
1105
- const style = document.createElement('style');
1106
- style.id = id;
1107
- style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
1108
- document.head.appendChild(style);
1109
- };
1110
- /**
1111
- * The AppElement interface provides properties and methods for manipulating
1112
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
1113
- * The AppElement interface also inherits the properties and methods of the
1114
- * {@link HTMLElement} interface.
1115
- *
1116
- * The element is sized like a replaced element such as `<video>`: a block-level box that the
1117
- * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
1118
- * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
1119
- * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
1120
- * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
1121
- *
1122
- * @elementSummary The `<pc-app>` element creates a PlayCanvas application and the canvas it renders
1123
- * into, and is the root of every scene. It holds the `<pc-asset>`, `<pc-material>`, `<pc-wasm>` and
1124
- * `<pc-scene>` elements, and the page's CSS sizes it, as it would a `<video>`.
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.
1125
886
  *
1126
- * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
1127
- * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
1128
- * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
1129
- * not bubble.
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.
1130
890
  *
1131
- * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
1132
- * could be created (for example, a browser with WebGL disabled). `message` names the requested
1133
- * backends and `error` holds the underlying failure. The element never becomes ready
1134
- * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
1135
- * re-inserting it retries the boot with its current attributes. Does not bubble.
891
+ * @internal
1136
892
  */
1137
- class AppElement extends AsyncElement {
893
+ class PointerController {
894
+ _host;
1138
895
  /**
1139
- * The canvas element.
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.
1140
901
  */
902
+ _generation = 0;
903
+ _app = null;
1141
904
  _canvas = null;
1142
- _alpha = true;
1143
- _backend = 'webgpu';
1144
- _antialias = true;
1145
- _depthBuffer = true;
1146
- _stencilBuffer = true;
1147
- _maxPixelRatio = Infinity;
1148
- _loadingBar = true;
1149
- /**
1150
- * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
1151
- * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
1152
- * cleared on disconnect so a re-connected element boots from its current attributes.
1153
- */
1154
- _optionsLocked = false;
1155
- _bar = null;
1156
- /**
1157
- * Whether the application has created its initial entity hierarchy. Read by EntityElement to
1158
- * decide whether a newly connected element must create its entity itself or leave it to the
1159
- * boot sweep.
1160
- * @internal
1161
- */
1162
- _hierarchyReady = false;
1163
- /**
1164
- * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
1165
- * itself wherever it resumes from an await if the value has moved on — so a boot whose
1166
- * element was removed cannot complete against a torn-down element, and a boot whose element
1167
- * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
1168
- */
1169
- _bootGeneration = 0;
1170
- /**
1171
- * The elements backing this application's entities, keyed by the entity itself. Registered
1172
- * by entity-owning elements at creation (pc-entity, and pc-model for its host) and by
1173
- * NodeElement at binding, and removed when an entity is destroyed or unbound, this joins
1174
- * engine scene nodes back to their owning elements by identity - never by name.
1175
- */
1176
- _entityElements = new Map();
1177
905
  _picker = null;
1178
906
  _hoveredEntity = null;
1179
907
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
@@ -1192,7 +920,7 @@ class AppElement extends AsyncElement {
1192
920
  * which is also what keeps those two canvas listeners attached.
1193
921
  */
1194
922
  _downPicks = new Map();
1195
- /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
923
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
1196
924
  _clickListened = false;
1197
925
  /**
1198
926
  * The previous click's target, time and count, for chaining successive clicks into the
@@ -1201,504 +929,194 @@ class AppElement extends AsyncElement {
1201
929
  _lastClick = null;
1202
930
  /**
1203
931
  * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
1204
- * whose picks resolve in GPU order, not canvas-event order. Replaced on teardown, so a pick
1205
- * that never resolves cannot stall the dispatches of a later boot.
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.
1206
934
  */
1207
935
  _dispatchChain = Promise.resolve();
1208
- _app = null;
1209
- _loadProgress = 0;
1210
936
  /**
1211
- * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
1212
- * the application exists, and disconnected on teardown. `null` where ResizeObserver is
1213
- * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
937
+ * @param host - The services the controller reads from its host element.
1214
938
  */
1215
- _resizeObserver = null;
939
+ constructor(host) {
940
+ this._host = host;
941
+ }
1216
942
  /**
1217
- * The PlayCanvas application instance. `null` until the element is ready, and again once it
1218
- * has been removed from the document await {@link whenReady} or the element's `ready()`
1219
- * promise before accessing it.
1220
- * @returns The application instance, or `null`.
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.
1221
950
  */
1222
- get app() {
1223
- return this._app;
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();
1224
971
  }
1225
972
  /**
1226
- * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
1227
- * preloading begins (and again once the element has been removed from the document), and 1
1228
- * once preloading has finished — including when there was nothing to preload. Read this to
1229
- * initialize a loading UI; subsequent updates arrive via the `progress` event.
1230
- * @returns The preload progress.
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.
1231
976
  */
1232
- get loadProgress() {
1233
- return this._loadProgress;
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();
1234
1001
  }
1235
1002
  /**
1236
- * Creates a new AppElement instance.
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.
1237
1005
  *
1238
- * @ignore
1006
+ * @param width - The drawing buffer width.
1007
+ * @param height - The drawing buffer height.
1239
1008
  */
1240
- constructor() {
1241
- super();
1242
- // Track listeners for the synthesized events being added to and removed from descendant
1243
- // entities. Registered once here rather than on every boot - the sync no-ops while there
1244
- // is no canvas, and a re-booted element must not stack a second set.
1245
- SYNTHESIZED_EVENTS.forEach((type) => {
1246
- this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1247
- this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
1248
- });
1009
+ resize(width, height) {
1010
+ this._picker?.resize(width, height);
1249
1011
  }
1250
- async connectedCallback() {
1251
- const generation = ++this._bootGeneration;
1252
- // Installed before the loading bar is created: the bar anchors to this element, which
1253
- // these styles make a positioned block box
1254
- ensureBaseStyles();
1255
- // Created before the first await, so the bar is visible while modules and the graphics
1256
- // device are created, and exists before any disconnect could need to clean it up
1257
- if (this._loadingBar && !this._bar) {
1258
- this._bar = new LoadingBar(this);
1259
- }
1260
- // Upgrade the subtree before reading anything out of it. A subtree cloned from a
1261
- // <template> arrives entirely unupgraded - template content lives in an inert document,
1262
- // where custom element definitions are never looked up - and appending the clone upgrades
1263
- // its elements in tree order, this one before its descendants. The module query below would
1264
- // otherwise find plain HTMLElements with no _getLoadPromise to call, and the boot would die
1265
- // there, leaving the element permanently unready: no canvas, no entities, no application.
1266
- //
1267
- // Upgrading is the fix here rather than skipping whatever has not upgraded, because a
1268
- // <pc-wasm> is the one child that nothing else ever builds on its own behalf - skipping
1269
- // it would drop the wasm module the app asked for, silently and only for cloned apps.
1270
- // Upgrading runs each descendant's connectedCallback synchronously, a few lines earlier
1271
- // than the parser's path runs them but into the same state they see there: no application
1272
- // yet and _hierarchyReady false, so they defer to the sweeps below. A descendant that
1273
- // disconnects this element from there is caught by the generation check after the await,
1274
- // as any other disconnect is. An already-upgraded subtree - every other insertion path -
1275
- // is left completely untouched.
1276
- customElements.upgrade(this);
1277
- // Get all pc-wasm elements that are direct children of the pc-app element
1278
- const wasmElements = this.querySelectorAll(':scope > pc-wasm');
1279
- // Wait for all modules to load
1280
- await Promise.all(Array.from(wasmElements).map((element) => element._getLoadPromise()));
1281
- // The element may have been removed while the modules loaded. Nothing beyond the loading
1282
- // bar exists yet, and disconnectedCallback has already destroyed that.
1283
- if (generation !== this._bootGeneration) {
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)
1284
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
+ }
1285
1031
  }
1286
- // Create and append the canvas, filling the element's content box - the page sizes the
1287
- // element, and everything else follows. touch-action: none keeps touch drags driving the
1288
- // engine's input handlers instead of scrolling the page.
1289
- this._canvas = document.createElement('canvas');
1290
- this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
1291
- this.appendChild(this._canvas);
1292
- // Configure device types based on backend selection
1293
- const backendToDeviceTypes = {
1294
- webgpu: ['webgpu', 'webgl2'], // fallback to webgl2 if webgpu not available
1295
- webgl2: ['webgl2'],
1296
- null: ['null']
1297
- };
1298
- const deviceTypes = backendToDeviceTypes[this._backend] || [];
1299
- this._optionsLocked = true;
1300
- // createGraphicsDevice appends its final null-device fallback to the array in place, so
1301
- // the requested list is captured now for the failure message.
1302
- const requested = deviceTypes.join(', ');
1303
- let device;
1304
- try {
1305
- device = await createGraphicsDevice(this._canvas, {
1306
- // @ts-ignore - alpha needs to be documented
1307
- alpha: this._alpha,
1308
- antialias: this._antialias,
1309
- depth: this._depthBuffer,
1310
- deviceTypes: deviceTypes,
1311
- stencil: this._stencilBuffer
1312
- });
1313
- }
1314
- catch (error) {
1315
- // The element may have been removed while device creation was failing. The teardown
1316
- // has already cleaned up, and the failure belongs to a boot that no longer owns the
1317
- // element.
1318
- if (generation !== this._bootGeneration) {
1032
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1033
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1034
+ if (!handler)
1319
1035
  return;
1036
+ if (needed.has(canvasType)) {
1037
+ canvas.addEventListener(canvasType, handler);
1320
1038
  }
1321
- // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
1322
- // zero - before announcing the failure. Readiness deliberately stays pending: nothing
1323
- // it would announce (the app, the entity hierarchy) exists, so a device-less element
1324
- // joins the documented never-ready cases and the failure surfaces through the error
1325
- // event instead.
1326
- if (this._canvas && this.contains(this._canvas)) {
1327
- this.removeChild(this._canvas);
1039
+ else {
1040
+ canvas.removeEventListener(canvasType, handler);
1328
1041
  }
1329
- this._canvas = null;
1330
- this._bar?.destroy();
1331
- this._bar = null;
1332
- const reason = error instanceof Error ? error.message : String(error);
1333
- const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
1334
- console.error(message, error);
1335
- this.dispatchEvent(new ErrorEvent('error', { message, error }));
1336
- return;
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;
1337
1061
  }
1338
- // The element may have been removed while the device was created. disconnectedCallback
1339
- // has already cleaned up the canvas; the device was created inside the await, so it is
1340
- // this boot's to release.
1341
- if (generation !== this._bootGeneration) {
1342
- device.destroy();
1343
- return;
1344
- }
1345
- // Assigned rather than resolved to a number here: the engine caps against the live
1346
- // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
1347
- // display when a window moves between monitors of differing density.
1348
- device.maxPixelRatio = this._maxPixelRatio;
1349
- const createOptions = new AppOptions();
1350
- createOptions.graphicsDevice = device;
1351
- createOptions.keyboard = new Keyboard(window);
1352
- createOptions.mouse = new Mouse(this._canvas);
1353
- createOptions.elementInput = new ElementInput(this._canvas, {
1354
- useMouse: true,
1355
- useTouch: true
1356
- });
1357
- createOptions.componentSystems = [
1358
- AnimComponentSystem,
1359
- AnimationComponentSystem,
1360
- AudioListenerComponentSystem,
1361
- ButtonComponentSystem,
1362
- CameraComponentSystem,
1363
- CollisionComponentSystem,
1364
- ElementComponentSystem,
1365
- GSplatComponentSystem,
1366
- JointComponentSystem,
1367
- LayoutChildComponentSystem,
1368
- LayoutGroupComponentSystem,
1369
- LightComponentSystem,
1370
- ModelComponentSystem,
1371
- ParticleSystemComponentSystem,
1372
- RenderComponentSystem,
1373
- RigidBodyComponentSystem,
1374
- ScreenComponentSystem,
1375
- ScriptComponentSystem,
1376
- ScrollbarComponentSystem,
1377
- ScrollViewComponentSystem,
1378
- SoundComponentSystem,
1379
- SpriteComponentSystem,
1380
- ZoneComponentSystem
1381
- ];
1382
- createOptions.resourceHandlers = [
1383
- AnimClipHandler,
1384
- AnimationHandler,
1385
- AnimStateGraphHandler,
1386
- AudioHandler,
1387
- BinaryHandler,
1388
- CssHandler,
1389
- ContainerHandler,
1390
- CubemapHandler,
1391
- FolderHandler,
1392
- FontHandler,
1393
- GSplatHandler,
1394
- HierarchyHandler,
1395
- HtmlHandler,
1396
- JsonHandler,
1397
- MaterialHandler,
1398
- ModelHandler,
1399
- RenderHandler,
1400
- ScriptHandler,
1401
- SceneHandler,
1402
- ShaderHandler,
1403
- SpriteHandler,
1404
- TemplateHandler,
1405
- TextHandler,
1406
- TextureAtlasHandler,
1407
- TextureHandler
1408
- ];
1409
- createOptions.soundManager = new SoundManager();
1410
- createOptions.lightmapper = Lightmapper;
1411
- createOptions.batchManager = BatchManager;
1412
- createOptions.xr = XrManager;
1413
- const app = new AppBase(this._canvas);
1414
- this._app = app;
1415
- app.init(createOptions);
1416
- // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1417
- // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1418
- // from the canvas's client size
1419
- app.setCanvasFillMode(FILLMODE_NONE);
1420
- app.setCanvasResolution(RESOLUTION_AUTO);
1421
- this._pickerCreate();
1422
- // Track the element's box rather than the window: containers resize without any window
1423
- // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1424
- // ResizeObserver - there, the resolution set above is the only sizing that happens.
1425
- if (typeof ResizeObserver !== 'undefined') {
1426
- this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1427
- this._resizeObserver.observe(this);
1428
- }
1429
- // Get all pc-asset elements that are direct children of the pc-app element
1430
- const assetElements = this.querySelectorAll(':scope > pc-asset');
1431
- for (const assetElement of Array.from(assetElements)) {
1432
- assetElement._createAsset();
1433
- const asset = assetElement.asset;
1434
- if (asset) {
1435
- app.assets.add(asset);
1436
- // Adding a fileless asset (one built purely from data, such as a sprite)
1437
- // completes it synchronously, dispatching the element's load event - whose
1438
- // listeners may have removed this element. Stop before the next addition
1439
- // reaches the destroyed registry, and before orphan entities are created.
1440
- if (generation !== this._bootGeneration) {
1441
- return;
1442
- }
1443
- }
1444
- }
1445
- // Get all pc-material elements that are direct children of the pc-app element
1446
- const materialElements = this.querySelectorAll(':scope > pc-material');
1447
- Array.from(materialElements).forEach((materialElement) => {
1448
- materialElement._createMaterial();
1449
- });
1450
- // Create all entities. pc-model joins the sweep because it owns a host entity of its
1451
- // own; its instantiated content arrives later, beneath that host.
1452
- const ownerElements = this.querySelectorAll('pc-entity, pc-model');
1453
- Array.from(ownerElements).forEach((ownerElement) => {
1454
- ownerElement._createEntity(app);
1455
- });
1456
- // Build hierarchy
1457
- ownerElements.forEach((ownerElement) => {
1458
- ownerElement._buildHierarchy(app);
1459
- });
1460
- // Building the hierarchy dispatched each entity's ready event synchronously, and a
1461
- // listener may have removed the element. The sweep itself degrades safely - destroying
1462
- // the application nulls every element's entity, so the remaining builds no-op - but the
1463
- // teardown's reset must not be overwritten here.
1464
- if (generation !== this._bootGeneration) {
1465
- return;
1466
- }
1467
- this._hierarchyReady = true;
1468
- // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
1469
- // listener must be attached before preload() is called: an asset that is already loaded
1470
- // ticks synchronously inside it.
1471
- const total = app.assets.list({ preload: true }).length;
1472
- let loaded = 0;
1473
- const onPreloadProgress = () => {
1474
- loaded += 1;
1475
- this._loadProgress = loaded / total;
1476
- this._bar?.progress(loaded, total);
1477
- this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
1478
- };
1479
- app.on('preload:progress', onPreloadProgress);
1480
- this._loadProgress = total === 0 ? 1 : 0;
1481
- this._bar?.progress(0, total);
1482
- this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
1483
- // The progress dispatch above ran listeners synchronously, and one may have removed the
1484
- // element. The application is already destroyed - it must not be asked to preload.
1485
- if (generation !== this._bootGeneration) {
1486
- return;
1487
- }
1488
- // Load assets before starting the application
1489
- app.preload(() => {
1490
- // The element may have been removed while assets loaded. The application is already
1491
- // destroyed, so it must not be started — and readiness must not be signaled for a
1492
- // boot that no longer owns the element.
1493
- if (generation !== this._bootGeneration) {
1494
- return;
1495
- }
1496
- // Scope the counter to this preload pass, so a later app.preload() call by user code
1497
- // cannot push `loaded` past `total`
1498
- app.off('preload:progress', onPreloadProgress);
1499
- this._loadProgress = 1;
1500
- // Start the application
1501
- app.start();
1502
- // Dismiss the bar only once a frame has actually rendered; ready fires before the
1503
- // first rAF tick
1504
- app.once('frameend', () => this._bar?.complete());
1505
- this._onReady();
1506
- });
1507
- }
1508
- disconnectedCallback() {
1509
- // Invalidate any boot still in flight, so it abandons itself when it next resumes
1510
- // instead of completing against a torn-down element.
1511
- this._bootGeneration++;
1512
- this._optionsLocked = false;
1513
- this._pickerDestroy();
1514
- // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1515
- // unregister them - clear() covers any entity the engine no longer reached.
1516
- if (this._app) {
1517
- this._app.destroy();
1518
- this._app = null;
1519
- }
1520
- this._entityElements.clear();
1521
- this._loadProgress = 0;
1522
- this._bar?.destroy();
1523
- this._bar = null;
1524
- // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1525
- // must neither see a hierarchy that no longer exists nor resume against a readiness that
1526
- // no longer holds.
1527
- this._hierarchyReady = false;
1528
- this._resetReady();
1529
- // Stop tracking the element's size
1530
- this._resizeObserver?.disconnect();
1531
- this._resizeObserver = null;
1532
- // Remove the canvas
1533
- if (this._canvas && this.contains(this._canvas)) {
1534
- this.removeChild(this._canvas);
1535
- this._canvas = null;
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;
1536
1079
  }
1080
+ return null;
1537
1081
  }
1538
1082
  /**
1539
- * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1540
- * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1541
- * an XR session presents - the session owns the buffer size.
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.
1542
1092
  */
1543
- _syncCanvasSize() {
1544
- if (!this.app || this.app.xr?.active) {
1545
- return;
1546
- }
1547
- this.app.updateCanvasSize();
1548
- const { width, height } = this.app.graphicsDevice;
1549
- this._picker?.resize(width, height);
1550
- }
1551
- _pickerCreate() {
1552
- const { width, height } = this.app.graphicsDevice;
1553
- this._picker = new Picker(this.app, width, height);
1554
- // Create bound handlers but don't attach them yet. The move handler is async, so it is
1555
- // wrapped to discard the promise - a listener must not return one.
1556
- const listener = (handler) => {
1557
- return (event) => {
1558
- handler.call(this, event);
1559
- };
1560
- };
1561
- this._pointerHandlers.pointermove = listener(this._onPointerMove);
1562
- this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1563
- this._pointerHandlers.pointerup = listener(this._onPointerUp);
1564
- this._pointerHandlers.pointercancel = (event) => {
1565
- this._downPicks.delete(event.pointerId);
1566
- };
1567
- // Attach canvas listeners for element listeners registered before this boot (e.g.
1568
- // handlers created from inline attributes when their elements were first upgraded, or
1569
- // listeners carried over from before a re-boot)
1570
- this._syncCanvasListeners();
1571
- }
1572
- _pickerDestroy() {
1573
- if (this._canvas) {
1574
- Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
1575
- if (handler) {
1576
- this._canvas.removeEventListener(type, handler);
1577
- }
1578
- });
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 };
1579
1097
  }
1580
- this._picker = null;
1581
- this._hoveredEntity = null;
1582
- this._pointerHandlers = {
1583
- pointermove: null,
1584
- pointerdown: null,
1585
- pointerup: null,
1586
- pointercancel: null
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
1587
1104
  };
1588
- this._downPicks.clear();
1589
- this._clickListened = false;
1590
- this._lastClick = null;
1591
- // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
1592
- this._dispatchChain = Promise.resolve();
1593
1105
  }
1594
1106
  /**
1595
- * Registers the element that fronts an entity. Called by EntityElement when it creates its
1596
- * entity, and by NodeElement when it binds one.
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.
1597
1114
  *
1598
- * @param entity - The entity.
1599
- * @param element - The element that fronts it.
1600
- * @internal
1601
- */
1602
- _registerEntityElement(entity, element) {
1603
- this._entityElements.set(entity, element);
1604
- }
1605
- /**
1606
- * Removes the registration for a destroyed entity. Called by EntityElement.
1607
- *
1608
- * @param entity - The entity.
1609
- * @internal
1610
- */
1611
- _unregisterEntityElement(entity) {
1612
- this._entityElements.delete(entity);
1613
- }
1614
- /**
1615
- * Returns the `<pc-entity>`, `<pc-model>` or `<pc-node>` element whose backing entity is
1616
- * `entity`, or `null` if the entity is not fronted by an element of this application - for
1617
- * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1618
- * through the engine API.
1619
- *
1620
- * @param entity - The entity to look up.
1621
- * @returns The element fronting the entity, or `null`.
1622
- */
1623
- elementFromEntity(entity) {
1624
- return this._entityElements.get(entity) ?? null;
1625
- }
1626
- /**
1627
- * Resolves the element that owns hover for a picked node: the nearest node up the parent
1628
- * chain - starting with the node itself - whose element listens for any of the hover event
1629
- * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
1630
- * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
1631
- * to hover rather than swallowing a listening ancestor's enter/leave pair.
1632
- *
1633
- * @param node - The picked node, or `null`.
1634
- * @returns The hover-owning element, or `null`.
1635
- */
1636
- _hoverTarget(node) {
1637
- while (node !== null) {
1638
- const element = this._entityElements.get(node);
1639
- if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
1640
- return element;
1641
- }
1642
- node = node.parent;
1643
- }
1644
- return null;
1645
- }
1646
- /**
1647
- * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
1648
- * on an unlistened child still reaches a listening ancestor.
1649
- *
1650
- * @param node - The picked node, or `null`.
1651
- * @param type - The pointer event type a listener is required for.
1652
- * @returns The nearest listening element, or `null`.
1653
- */
1654
- _elementWithListener(node, type) {
1655
- while (node !== null) {
1656
- const element = this._entityElements.get(node);
1657
- if (element?._hasListeners(type)) {
1658
- return element;
1659
- }
1660
- node = node.parent;
1661
- }
1662
- return null;
1663
- }
1664
- /**
1665
- * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1666
- * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1667
- * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1668
- * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1669
- * coordinates correspond to no real geometry.
1670
- *
1671
- * @param event - The pointer event to convert.
1672
- * @param canvas - The canvas the event was dispatched on.
1673
- * @returns The buffer-space coordinates, and whether they were actually mapped.
1674
- */
1675
- _getPickerCoordinates(event, canvas) {
1676
- const canvasRect = canvas.getBoundingClientRect();
1677
- if (canvasRect.width === 0 || canvasRect.height === 0) {
1678
- return { x: event.clientX, y: event.clientY, mapped: false };
1679
- }
1680
- const scaleX = canvas.width / canvasRect.width;
1681
- const scaleY = canvas.height / canvasRect.height;
1682
- return {
1683
- x: (event.clientX - canvasRect.left) * scaleX,
1684
- y: (event.clientY - canvasRect.top) * scaleY,
1685
- mapped: true
1686
- };
1687
- }
1688
- /**
1689
- * Whether a camera's viewport contains the point. A camera renders into its normalized
1690
- * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1691
- * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1692
- * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1693
- * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1694
- * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1695
- * holds nothing there.
1696
- *
1697
- * @param camera - The camera to test.
1698
- * @param x - The x coordinate, in buffer space.
1699
- * @param y - The y coordinate, in buffer space.
1700
- * @param canvas - The canvas the coordinates are relative to.
1701
- * @returns Whether the camera's viewport contains the point.
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.
1702
1120
  */
1703
1121
  _cameraContains(camera, x, y, canvas) {
1704
1122
  const rect = camera.rect;
@@ -1727,7 +1145,8 @@ class AppElement extends AsyncElement {
1727
1145
  * @returns The graph node under the pointer, or `null` if nothing was hit.
1728
1146
  */
1729
1147
  async _pickNode(event) {
1730
- const app = this.app;
1148
+ const generation = this._generation;
1149
+ const app = this._app;
1731
1150
  const picker = this._picker;
1732
1151
  const canvas = this._canvas;
1733
1152
  if (!app || !picker || !canvas)
@@ -1747,8 +1166,9 @@ class AppElement extends AsyncElement {
1747
1166
  continue;
1748
1167
  picker.prepare(camera, app.scene);
1749
1168
  const selection = await picker.getSelectionAsync(x, y);
1750
- // The element may have disconnected while the read back was in flight.
1751
- if (!this._picker || !this.app)
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)
1752
1172
  return null;
1753
1173
  if (selection.length > 0) {
1754
1174
  const item = selection[0];
@@ -1763,145 +1183,588 @@ class AppElement extends AsyncElement {
1763
1183
  return null;
1764
1184
  }
1765
1185
  async _onPointerMove(event) {
1766
- if (!this._picker || !this.app)
1186
+ if (!this._picker || !this._app)
1767
1187
  return;
1768
1188
  // Moves arrive faster than a pick resolves, so results can land out of order. Only the
1769
1189
  // newest pick may update the hover state - an older one describes a pointer position the
1770
- // user has already left.
1190
+ // user has already left, and one from an earlier connection describes a scene that no
1191
+ // longer exists.
1192
+ const generation = this._generation;
1771
1193
  const token = ++this._pickToken;
1772
1194
  const node = await this._pickNode(event);
1773
- if (token !== this._pickToken || !this._picker)
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
+
1301
+ /**
1302
+ * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
1303
+ * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
1304
+ * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
1305
+ * so any page rule - however plain - overrides these defaults.
1306
+ */
1307
+ const ensureBaseStyles = () => {
1308
+ const id = 'pc-app-styles';
1309
+ if (document.getElementById(id)) {
1310
+ return;
1311
+ }
1312
+ const style = document.createElement('style');
1313
+ style.id = id;
1314
+ style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
1315
+ document.head.appendChild(style);
1316
+ };
1317
+ /**
1318
+ * The AppElement interface provides properties and methods for manipulating
1319
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
1320
+ * The AppElement interface also inherits the properties and methods of the
1321
+ * {@link HTMLElement} interface.
1322
+ *
1323
+ * The element is sized like a replaced element such as `<video>`: a block-level box that the
1324
+ * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
1325
+ * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
1326
+ * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
1327
+ * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
1328
+ *
1329
+ * @elementSummary The `<pc-app>` element creates a PlayCanvas application and the canvas it renders
1330
+ * into, and is the root of every scene. It holds the `<pc-asset>`, `<pc-material>`, `<pc-wasm>` and
1331
+ * `<pc-scene>` elements, and the page's CSS sizes it, as it would a `<video>`.
1332
+ *
1333
+ * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
1334
+ * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
1335
+ * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
1336
+ * not bubble.
1337
+ *
1338
+ * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
1339
+ * could be created (for example, a browser with WebGL disabled). `message` names the requested
1340
+ * backends and `error` holds the underlying failure. The element never becomes ready
1341
+ * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
1342
+ * re-inserting it retries the boot with its current attributes. Does not bubble.
1343
+ */
1344
+ class AppElement extends AsyncElement {
1345
+ /**
1346
+ * The canvas element.
1347
+ */
1348
+ _canvas = null;
1349
+ _alpha = true;
1350
+ _backend = 'webgpu';
1351
+ _antialias = true;
1352
+ _depthBuffer = true;
1353
+ _stencilBuffer = true;
1354
+ _maxPixelRatio = Infinity;
1355
+ _loadingBar = true;
1356
+ /**
1357
+ * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
1358
+ * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
1359
+ * cleared on disconnect so a re-connected element boots from its current attributes.
1360
+ */
1361
+ _optionsLocked = false;
1362
+ _bar = null;
1363
+ /**
1364
+ * Whether the application has created its initial entity hierarchy. Read by EntityElement to
1365
+ * decide whether a newly connected element must create its entity itself or leave it to the
1366
+ * boot sweep.
1367
+ * @internal
1368
+ */
1369
+ _hierarchyReady = false;
1370
+ /**
1371
+ * Incremented on every connect and disconnect. Boot captures the value on entry and abandons
1372
+ * itself wherever it resumes from an await if the value has moved on — so a boot whose
1373
+ * element was removed cannot complete against a torn-down element, and a boot whose element
1374
+ * was removed and re-inserted (which starts a boot of its own) cannot race the newer one.
1375
+ */
1376
+ _bootGeneration = 0;
1377
+ /**
1378
+ * The elements backing this application's entities, keyed by the entity itself. Registered
1379
+ * by entity-owning elements at creation (pc-entity, and pc-model for its host) and by
1380
+ * NodeElement at binding, and removed when an entity is destroyed or unbound, this joins
1381
+ * engine scene nodes back to their owning elements by identity - never by name.
1382
+ */
1383
+ _entityElements = new Map();
1384
+ /**
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.
1389
+ */
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
+ });
1394
+ _app = null;
1395
+ _loadProgress = 0;
1396
+ /**
1397
+ * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
1398
+ * the application exists, and disconnected on teardown. `null` where ResizeObserver is
1399
+ * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
1400
+ */
1401
+ _resizeObserver = null;
1402
+ /**
1403
+ * The PlayCanvas application instance. `null` until the element is ready, and again once it
1404
+ * has been removed from the document — await {@link whenReady} or the element's `ready()`
1405
+ * promise before accessing it.
1406
+ * @returns The application instance, or `null`.
1407
+ */
1408
+ get app() {
1409
+ return this._app;
1410
+ }
1411
+ /**
1412
+ * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
1413
+ * preloading begins (and again once the element has been removed from the document), and 1
1414
+ * once preloading has finished — including when there was nothing to preload. Read this to
1415
+ * initialize a loading UI; subsequent updates arrive via the `progress` event.
1416
+ * @returns The preload progress.
1417
+ */
1418
+ get loadProgress() {
1419
+ return this._loadProgress;
1420
+ }
1421
+ /**
1422
+ * Creates a new AppElement instance.
1423
+ *
1424
+ * @ignore
1425
+ */
1426
+ constructor() {
1427
+ super();
1428
+ // Track listeners for the synthesized events being added to and removed from descendant
1429
+ // entities. Registered once here rather than on every boot - the sync no-ops while there
1430
+ // is no canvas, and a re-booted element must not stack a second set.
1431
+ SYNTHESIZED_EVENTS.forEach((type) => {
1432
+ this.addEventListener(`${type}:connect`, () => this._pointer.syncListeners());
1433
+ this.addEventListener(`${type}:disconnect`, () => this._pointer.syncListeners());
1434
+ });
1435
+ }
1436
+ async connectedCallback() {
1437
+ const generation = ++this._bootGeneration;
1438
+ // Installed before the loading bar is created: the bar anchors to this element, which
1439
+ // these styles make a positioned block box
1440
+ ensureBaseStyles();
1441
+ // Created before the first await, so the bar is visible while modules and the graphics
1442
+ // device are created, and exists before any disconnect could need to clean it up
1443
+ if (this._loadingBar && !this._bar) {
1444
+ this._bar = new LoadingBar(this);
1445
+ }
1446
+ // Upgrade the subtree before reading anything out of it. A subtree cloned from a
1447
+ // <template> arrives entirely unupgraded - template content lives in an inert document,
1448
+ // where custom element definitions are never looked up - and appending the clone upgrades
1449
+ // its elements in tree order, this one before its descendants. The module query below would
1450
+ // otherwise find plain HTMLElements with no _getLoadPromise to call, and the boot would die
1451
+ // there, leaving the element permanently unready: no canvas, no entities, no application.
1452
+ //
1453
+ // Upgrading is the fix here rather than skipping whatever has not upgraded, because a
1454
+ // <pc-wasm> is the one child that nothing else ever builds on its own behalf - skipping
1455
+ // it would drop the wasm module the app asked for, silently and only for cloned apps.
1456
+ // Upgrading runs each descendant's connectedCallback synchronously, a few lines earlier
1457
+ // than the parser's path runs them but into the same state they see there: no application
1458
+ // yet and _hierarchyReady false, so they defer to the sweeps below. A descendant that
1459
+ // disconnects this element from there is caught by the generation check after the await,
1460
+ // as any other disconnect is. An already-upgraded subtree - every other insertion path -
1461
+ // is left completely untouched.
1462
+ customElements.upgrade(this);
1463
+ // Get all pc-wasm elements that are direct children of the pc-app element
1464
+ const wasmElements = this.querySelectorAll(':scope > pc-wasm');
1465
+ // Wait for all modules to load
1466
+ await Promise.all(Array.from(wasmElements).map((element) => element._getLoadPromise()));
1467
+ // The element may have been removed while the modules loaded. Nothing beyond the loading
1468
+ // bar exists yet, and disconnectedCallback has already destroyed that.
1469
+ if (generation !== this._bootGeneration) {
1470
+ return;
1471
+ }
1472
+ // Create and append the canvas, filling the element's content box - the page sizes the
1473
+ // element, and everything else follows. touch-action: none keeps touch drags driving the
1474
+ // engine's input handlers instead of scrolling the page.
1475
+ this._canvas = document.createElement('canvas');
1476
+ this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
1477
+ this.appendChild(this._canvas);
1478
+ // Configure device types based on backend selection
1479
+ const backendToDeviceTypes = {
1480
+ webgpu: ['webgpu', 'webgl2'], // fallback to webgl2 if webgpu not available
1481
+ webgl2: ['webgl2'],
1482
+ null: ['null']
1483
+ };
1484
+ const deviceTypes = backendToDeviceTypes[this._backend] || [];
1485
+ this._optionsLocked = true;
1486
+ // createGraphicsDevice appends its final null-device fallback to the array in place, so
1487
+ // the requested list is captured now for the failure message.
1488
+ const requested = deviceTypes.join(', ');
1489
+ let device;
1490
+ try {
1491
+ device = await createGraphicsDevice(this._canvas, {
1492
+ // @ts-ignore - alpha needs to be documented
1493
+ alpha: this._alpha,
1494
+ antialias: this._antialias,
1495
+ depth: this._depthBuffer,
1496
+ deviceTypes: deviceTypes,
1497
+ stencil: this._stencilBuffer
1498
+ });
1499
+ }
1500
+ catch (error) {
1501
+ // The element may have been removed while device creation was failing. The teardown
1502
+ // has already cleaned up, and the failure belongs to a boot that no longer owns the
1503
+ // element.
1504
+ if (generation !== this._bootGeneration) {
1505
+ return;
1506
+ }
1507
+ // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
1508
+ // zero - before announcing the failure. Readiness deliberately stays pending: nothing
1509
+ // it would announce (the app, the entity hierarchy) exists, so a device-less element
1510
+ // joins the documented never-ready cases and the failure surfaces through the error
1511
+ // event instead.
1512
+ if (this._canvas && this.contains(this._canvas)) {
1513
+ this.removeChild(this._canvas);
1514
+ }
1515
+ this._canvas = null;
1516
+ this._bar?.destroy();
1517
+ this._bar = null;
1518
+ const reason = error instanceof Error ? error.message : String(error);
1519
+ const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
1520
+ console.error(message, error);
1521
+ this.dispatchEvent(new ErrorEvent('error', { message, error }));
1522
+ return;
1523
+ }
1524
+ // The element may have been removed while the device was created. disconnectedCallback
1525
+ // has already cleaned up the canvas; the device was created inside the await, so it is
1526
+ // this boot's to release.
1527
+ if (generation !== this._bootGeneration) {
1528
+ device.destroy();
1529
+ return;
1530
+ }
1531
+ // Assigned rather than resolved to a number here: the engine caps against the live
1532
+ // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
1533
+ // display when a window moves between monitors of differing density.
1534
+ device.maxPixelRatio = this._maxPixelRatio;
1535
+ const createOptions = new AppOptions();
1536
+ createOptions.graphicsDevice = device;
1537
+ createOptions.keyboard = new Keyboard(window);
1538
+ createOptions.mouse = new Mouse(this._canvas);
1539
+ createOptions.elementInput = new ElementInput(this._canvas, {
1540
+ useMouse: true,
1541
+ useTouch: true
1542
+ });
1543
+ createOptions.componentSystems = [
1544
+ AnimComponentSystem,
1545
+ AnimationComponentSystem,
1546
+ AudioListenerComponentSystem,
1547
+ ButtonComponentSystem,
1548
+ CameraComponentSystem,
1549
+ CollisionComponentSystem,
1550
+ ElementComponentSystem,
1551
+ GSplatComponentSystem,
1552
+ JointComponentSystem,
1553
+ LayoutChildComponentSystem,
1554
+ LayoutGroupComponentSystem,
1555
+ LightComponentSystem,
1556
+ ModelComponentSystem,
1557
+ ParticleSystemComponentSystem,
1558
+ RenderComponentSystem,
1559
+ RigidBodyComponentSystem,
1560
+ ScreenComponentSystem,
1561
+ ScriptComponentSystem,
1562
+ ScrollbarComponentSystem,
1563
+ ScrollViewComponentSystem,
1564
+ SoundComponentSystem,
1565
+ SpriteComponentSystem,
1566
+ ZoneComponentSystem
1567
+ ];
1568
+ createOptions.resourceHandlers = [
1569
+ AnimClipHandler,
1570
+ AnimationHandler,
1571
+ AnimStateGraphHandler,
1572
+ AudioHandler,
1573
+ BinaryHandler,
1574
+ CssHandler,
1575
+ ContainerHandler,
1576
+ CubemapHandler,
1577
+ FolderHandler,
1578
+ FontHandler,
1579
+ GSplatHandler,
1580
+ HierarchyHandler,
1581
+ HtmlHandler,
1582
+ JsonHandler,
1583
+ MaterialHandler,
1584
+ ModelHandler,
1585
+ RenderHandler,
1586
+ ScriptHandler,
1587
+ SceneHandler,
1588
+ ShaderHandler,
1589
+ SpriteHandler,
1590
+ TemplateHandler,
1591
+ TextHandler,
1592
+ TextureAtlasHandler,
1593
+ TextureHandler
1594
+ ];
1595
+ createOptions.soundManager = new SoundManager();
1596
+ createOptions.lightmapper = Lightmapper;
1597
+ createOptions.batchManager = BatchManager;
1598
+ createOptions.xr = XrManager;
1599
+ const app = new AppBase(this._canvas);
1600
+ this._app = app;
1601
+ app.init(createOptions);
1602
+ // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1603
+ // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1604
+ // from the canvas's client size
1605
+ app.setCanvasFillMode(FILLMODE_NONE);
1606
+ app.setCanvasResolution(RESOLUTION_AUTO);
1607
+ this._pointer.connect(app, this._canvas);
1608
+ // Track the element's box rather than the window: containers resize without any window
1609
+ // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1610
+ // ResizeObserver - there, the resolution set above is the only sizing that happens.
1611
+ if (typeof ResizeObserver !== 'undefined') {
1612
+ this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1613
+ this._resizeObserver.observe(this);
1614
+ }
1615
+ // Get all pc-asset elements that are direct children of the pc-app element
1616
+ const assetElements = this.querySelectorAll(':scope > pc-asset');
1617
+ for (const assetElement of Array.from(assetElements)) {
1618
+ assetElement._createAsset();
1619
+ const asset = assetElement.asset;
1620
+ if (asset) {
1621
+ app.assets.add(asset);
1622
+ // Adding a fileless asset (one built purely from data, such as a sprite)
1623
+ // completes it synchronously, dispatching the element's load event - whose
1624
+ // listeners may have removed this element. Stop before the next addition
1625
+ // reaches the destroyed registry, and before orphan entities are created.
1626
+ if (generation !== this._bootGeneration) {
1627
+ return;
1628
+ }
1629
+ }
1630
+ }
1631
+ // Get all pc-material elements that are direct children of the pc-app element
1632
+ const materialElements = this.querySelectorAll(':scope > pc-material');
1633
+ Array.from(materialElements).forEach((materialElement) => {
1634
+ materialElement._createMaterial();
1635
+ });
1636
+ // Create all entities. pc-model joins the sweep because it owns a host entity of its
1637
+ // own; its instantiated content arrives later, beneath that host.
1638
+ const ownerElements = this.querySelectorAll('pc-entity, pc-model');
1639
+ Array.from(ownerElements).forEach((ownerElement) => {
1640
+ ownerElement._createEntity(app);
1641
+ });
1642
+ // Build hierarchy
1643
+ ownerElements.forEach((ownerElement) => {
1644
+ ownerElement._buildHierarchy(app);
1645
+ });
1646
+ // Building the hierarchy dispatched each entity's ready event synchronously, and a
1647
+ // listener may have removed the element. The sweep itself degrades safely - destroying
1648
+ // the application nulls every element's entity, so the remaining builds no-op - but the
1649
+ // teardown's reset must not be overwritten here.
1650
+ if (generation !== this._bootGeneration) {
1774
1651
  return;
1775
- // The hovered element is the nearest one up the node's parent chain with a hover
1776
- // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
1777
- // type below: having any hover listener selects the target, each event needs its own.
1778
- const newHoverEntity = this._hoverTarget(node);
1779
- // Handle enter/leave events
1780
- if (this._hoveredEntity !== newHoverEntity) {
1781
- if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
1782
- this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
1783
- }
1784
- if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
1785
- newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
1652
+ }
1653
+ this._hierarchyReady = true;
1654
+ // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
1655
+ // listener must be attached before preload() is called: an asset that is already loaded
1656
+ // ticks synchronously inside it.
1657
+ const total = app.assets.list({ preload: true }).length;
1658
+ let loaded = 0;
1659
+ const onPreloadProgress = () => {
1660
+ loaded += 1;
1661
+ this._loadProgress = loaded / total;
1662
+ this._bar?.progress(loaded, total);
1663
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
1664
+ };
1665
+ app.on('preload:progress', onPreloadProgress);
1666
+ this._loadProgress = total === 0 ? 1 : 0;
1667
+ this._bar?.progress(0, total);
1668
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
1669
+ // The progress dispatch above ran listeners synchronously, and one may have removed the
1670
+ // element. The application is already destroyed - it must not be asked to preload.
1671
+ if (generation !== this._bootGeneration) {
1672
+ return;
1673
+ }
1674
+ // Load assets before starting the application
1675
+ app.preload(() => {
1676
+ // The element may have been removed while assets loaded. The application is already
1677
+ // destroyed, so it must not be started — and readiness must not be signaled for a
1678
+ // boot that no longer owns the element.
1679
+ if (generation !== this._bootGeneration) {
1680
+ return;
1786
1681
  }
1682
+ // Scope the counter to this preload pass, so a later app.preload() call by user code
1683
+ // cannot push `loaded` past `total`
1684
+ app.off('preload:progress', onPreloadProgress);
1685
+ this._loadProgress = 1;
1686
+ // Start the application
1687
+ app.start();
1688
+ // Dismiss the bar only once a frame has actually rendered; ready fires before the
1689
+ // first rAF tick
1690
+ app.once('frameend', () => this._bar?.complete());
1691
+ this._onReady();
1692
+ });
1693
+ }
1694
+ disconnectedCallback() {
1695
+ // Invalidate any boot still in flight, so it abandons itself when it next resumes
1696
+ // instead of completing against a torn-down element.
1697
+ this._bootGeneration++;
1698
+ this._optionsLocked = false;
1699
+ this._pointer.disconnect();
1700
+ // Clean up the application. Destroying it destroys every entity, whose destroy hooks
1701
+ // unregister them - clear() covers any entity the engine no longer reached.
1702
+ if (this._app) {
1703
+ this._app.destroy();
1704
+ this._app = null;
1787
1705
  }
1788
- // Update hover state
1789
- this._hoveredEntity = newHoverEntity;
1790
- // Handle pointermove event
1791
- if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
1792
- newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
1706
+ this._entityElements.clear();
1707
+ this._loadProgress = 0;
1708
+ this._bar?.destroy();
1709
+ this._bar = null;
1710
+ // Return the element to its pre-boot state, so re-inserting it boots afresh: descendants
1711
+ // must neither see a hierarchy that no longer exists nor resume against a readiness that
1712
+ // no longer holds.
1713
+ this._hierarchyReady = false;
1714
+ this._resetReady();
1715
+ // Stop tracking the element's size
1716
+ this._resizeObserver?.disconnect();
1717
+ this._resizeObserver = null;
1718
+ // Remove the canvas
1719
+ if (this._canvas && this.contains(this._canvas)) {
1720
+ this.removeChild(this._canvas);
1721
+ this._canvas = null;
1793
1722
  }
1794
1723
  }
1795
1724
  /**
1796
- * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
1797
- * canvas event handler - the order of appends is what carries canvas-event order. A step
1798
- * that rejects is reported and released, so the steps queued behind it still dispatch.
1799
- *
1800
- * @param step - The dispatch work to run once every earlier step has finished.
1725
+ * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1726
+ * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1727
+ * an XR session presents - the session owns the buffer size.
1801
1728
  */
1802
- _chainDispatch(step) {
1803
- this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
1804
- console.error(error);
1805
- });
1806
- }
1807
- _onPointerDown(event) {
1808
- if (!this._picker || !this.app)
1729
+ _syncCanvasSize() {
1730
+ if (!this.app || this.app.xr?.active) {
1809
1731
  return;
1810
- // Picks stay concurrent - only the dispatch of the results is serialized
1811
- const pick = this._pickNode(event);
1812
- // A click concludes on the matching pointerup, which needs to know what the press
1813
- // picked. Primary button only - the only button a click can conclude from - and only
1814
- // while click is listened for, since it is the click mapping that keeps the pointerup
1815
- // and pointercancel listeners attached to clean the entry up again.
1816
- if (this._clickListened && event.button === 0) {
1817
- this._downPicks.set(event.pointerId, pick);
1818
1732
  }
1819
- this._chainDispatch(async () => {
1820
- const node = await pick;
1821
- if (!this._picker)
1822
- return; // the element disconnected while the pick was in flight
1823
- const entityElement = this._elementWithListener(node, 'pointerdown');
1824
- if (entityElement) {
1825
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
1826
- }
1827
- });
1733
+ this.app.updateCanvasSize();
1734
+ const { width, height } = this.app.graphicsDevice;
1735
+ this._pointer.resize(width, height);
1828
1736
  }
1829
- _onPointerUp(event) {
1830
- if (!this._picker || !this.app)
1831
- return;
1832
- // The press pick this release may conclude as a click. Claimed synchronously, so the
1833
- // entry is gone before any other event for this pointer can be handled.
1834
- const downPick = this._downPicks.get(event.pointerId);
1835
- this._downPicks.delete(event.pointerId);
1836
- const pick = this._pickNode(event);
1837
- this._chainDispatch(async () => {
1838
- const node = await pick;
1839
- if (!this._picker)
1840
- return; // the element disconnected while the pick was in flight
1841
- const entityElement = this._elementWithListener(node, 'pointerup');
1842
- if (entityElement) {
1843
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1844
- }
1845
- });
1846
- // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1847
- // what the press and the release picked, for the primary button only. Appended after
1848
- // the release's own step, so it dispatches after the pointerup that concludes it.
1849
- if (!downPick || event.button !== 0)
1850
- return;
1851
- this._chainDispatch(async () => {
1852
- // A rejected pick was already reported by the press or release step that awaited it;
1853
- // here it just means no click can conclude.
1854
- const picked = await Promise.all([downPick, pick]).catch(() => null);
1855
- if (!picked || !this._picker)
1856
- return;
1857
- const [downNode, upNode] = picked;
1858
- const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
1859
- if (clickElement) {
1860
- const click = new PointerEvent('click', event);
1861
- // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1862
- // at 0 - but click is exempt: its detail is the click count, chained here as the
1863
- // platform chains it (same target, within the double-click window). Overridden
1864
- // with defineProperty because an event instance used as an init dict cannot have
1865
- // single fields replaced.
1866
- const time = performance.now();
1867
- const last = this._lastClick;
1868
- const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1869
- this._lastClick = { element: clickElement, time, count };
1870
- Object.defineProperty(click, 'detail', { value: count });
1871
- clickElement.dispatchEvent(click);
1872
- }
1873
- });
1737
+ /**
1738
+ * Registers the element that fronts an entity. Called by EntityElement when it creates its
1739
+ * entity, and by NodeElement when it binds one.
1740
+ *
1741
+ * @param entity - The entity.
1742
+ * @param element - The element that fronts it.
1743
+ * @internal
1744
+ */
1745
+ _registerEntityElement(entity, element) {
1746
+ this._entityElements.set(entity, element);
1874
1747
  }
1875
1748
  /**
1876
- * Attaches exactly the canvas listeners the tree's current element listeners need, and
1877
- * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1878
- * this element: several synthesized types can need the same canvas listener (enter, leave
1879
- * and move all ride the move pick; click rides the down/up pair), so one type's removal
1880
- * must not detach a listener another type still uses. Re-attaching an attached listener is
1881
- * a no-op by EventTarget semantics, so no attach state is kept.
1749
+ * Removes the registration for a destroyed entity. Called by EntityElement.
1750
+ *
1751
+ * @param entity - The entity.
1752
+ * @internal
1882
1753
  */
1883
- _syncCanvasListeners() {
1884
- const canvas = this._canvas;
1885
- if (!canvas)
1886
- return; // not booted yet: _pickerCreate syncs once the handlers exist
1887
- const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1888
- const needed = new Set();
1889
- for (const type of SYNTHESIZED_EVENTS) {
1890
- if (elements.some((element) => element._hasListeners(type))) {
1891
- canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1892
- }
1893
- }
1894
- this._clickListened = elements.some((element) => element._hasListeners('click'));
1895
- Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1896
- if (!handler)
1897
- return;
1898
- if (needed.has(canvasType)) {
1899
- canvas.addEventListener(canvasType, handler);
1900
- }
1901
- else {
1902
- canvas.removeEventListener(canvasType, handler);
1903
- }
1904
- });
1754
+ _unregisterEntityElement(entity) {
1755
+ this._entityElements.delete(entity);
1756
+ }
1757
+ /**
1758
+ * Returns the `<pc-entity>`, `<pc-model>` or `<pc-node>` element whose backing entity is
1759
+ * `entity`, or `null` if the entity is not fronted by an element of this application - for
1760
+ * example, an unbound node inside a model's instantiated hierarchy, or an entity created
1761
+ * through the engine API.
1762
+ *
1763
+ * @param entity - The entity to look up.
1764
+ * @returns The element fronting the entity, or `null`.
1765
+ */
1766
+ elementFromEntity(entity) {
1767
+ return this._entityElements.get(entity) ?? null;
1905
1768
  }
1906
1769
  /**
1907
1770
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -3277,31 +3140,133 @@ class AssetElement extends AsyncElement {
3277
3140
  else {
3278
3141
  this.srgb = null;
3279
3142
  }
3280
- break;
3143
+ break;
3144
+ }
3145
+ }
3146
+ }
3147
+ customElements.define('pc-asset', AssetElement);
3148
+ /**
3149
+ * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
3150
+ * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
3151
+ * resolves its references here, which is what makes `lazy` mean load on first use without any
3152
+ * consumer having to remember the load. The load is asynchronous - callers observe the asset's
3153
+ * `load` event for the resource.
3154
+ *
3155
+ * @param id - The `id` of the `<pc-asset>` element.
3156
+ * @returns The asset, or `undefined`.
3157
+ * @internal
3158
+ */
3159
+ const useAsset = (id) => {
3160
+ const asset = AssetElement.get(id);
3161
+ // load() ignores an asset that is already loaded or loading, so repeated resolution
3162
+ // costs nothing.
3163
+ if (asset) {
3164
+ asset.registry?.load(asset);
3165
+ }
3166
+ return asset;
3167
+ };
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
+ });
3281
3266
  }
3267
+ return asset;
3282
3268
  }
3283
3269
  }
3284
- customElements.define('pc-asset', AssetElement);
3285
- /**
3286
- * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
3287
- * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
3288
- * resolves its references here, which is what makes `lazy` mean load on first use without any
3289
- * consumer having to remember the load. The load is asynchronous - callers observe the asset's
3290
- * `load` event for the resource.
3291
- *
3292
- * @param id - The `id` of the `<pc-asset>` element.
3293
- * @returns The asset, or `undefined`.
3294
- * @internal
3295
- */
3296
- const useAsset = (id) => {
3297
- const asset = AssetElement.get(id);
3298
- // load() ignores an asset that is already loaded or loading, so repeated resolution
3299
- // costs nothing.
3300
- if (asset) {
3301
- asset.registry?.load(asset);
3302
- }
3303
- return asset;
3304
- };
3305
3270
 
3306
3271
  /**
3307
3272
  * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
@@ -3403,18 +3368,18 @@ class ModelElement extends EntityOwnerElement {
3403
3368
  _contentEntity = null;
3404
3369
  /**
3405
3370
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
3406
- * by a load when it starts. A load that resumes from an await or a load callback abandons
3407
- * itself if the value has moved on, so a superseded load can neither instantiate a second
3408
- * 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.
3409
3375
  */
3410
3376
  _loadGeneration = 0;
3411
3377
  /**
3412
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
3413
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
3414
- * 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.
3415
3381
  */
3416
- _loadHandle = null;
3417
- _errorHandle = null;
3382
+ _binding = new AssetBinding();
3418
3383
  /**
3419
3384
  * The root entity of the instantiated model content, parented beneath the host entity.
3420
3385
  * `null` until the container asset has loaded and been instantiated, after a failed load,
@@ -3505,7 +3470,7 @@ class ModelElement extends EntityOwnerElement {
3505
3470
  // resets the element. The generation guard comes first so a load suspended on an await
3506
3471
  // cannot resume against the torn-down element.
3507
3472
  this._loadGeneration++;
3508
- this._detachLoadHandlers();
3473
+ this._binding.cancel();
3509
3474
  this._entity?.destroy();
3510
3475
  }
3511
3476
  /**
@@ -3524,16 +3489,10 @@ class ModelElement extends EntityOwnerElement {
3524
3489
  */
3525
3490
  _onEntityDestroy(entity) {
3526
3491
  this._loadGeneration++;
3527
- this._detachLoadHandlers();
3492
+ this._binding.cancel();
3528
3493
  this._contentEntity = null;
3529
3494
  super._onEntityDestroy(entity);
3530
3495
  }
3531
- _detachLoadHandlers() {
3532
- this._loadHandle?.off();
3533
- this._loadHandle = null;
3534
- this._errorHandle?.off();
3535
- this._errorHandle = null;
3536
- }
3537
3496
  /**
3538
3497
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
3539
3498
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -3560,7 +3519,7 @@ class ModelElement extends EntityOwnerElement {
3560
3519
  this._destroyContent();
3561
3520
  // Supersede any load already in flight - only the newest load may instantiate
3562
3521
  const generation = ++this._loadGeneration;
3563
- this._detachLoadHandlers();
3522
+ this._binding.cancel();
3564
3523
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
3565
3524
  // content. A no-op on first connection, where readiness is still pending.
3566
3525
  this._resetReady();
@@ -3586,39 +3545,23 @@ class ModelElement extends EntityOwnerElement {
3586
3545
  this._onReady();
3587
3546
  return;
3588
3547
  }
3589
- const asset = useAsset(this._asset);
3590
- if (!asset) {
3591
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
3592
- // silently pending.
3593
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
3594
- return;
3595
- }
3596
- if (asset.loaded) {
3597
- this._instantiate(asset.resource);
3598
- }
3599
- else {
3600
- // The generation is re-checked even though a superseded handler is detached: the
3601
- // detach relies on how the engine's event emitter treats removal, while the check
3602
- // holds on its own. Whichever of load/error fires first detaches the other.
3603
- this._loadHandle = asset.once('load', () => {
3604
- this._detachLoadHandlers();
3605
- if (generation !== this._loadGeneration) {
3606
- return;
3607
- }
3608
- this._instantiate(asset.resource);
3609
- });
3610
- this._errorHandle = asset.once('error', (err) => {
3611
- this._detachLoadHandlers();
3612
- if (generation !== this._loadGeneration) {
3613
- return;
3614
- }
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) => {
3615
3553
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
3616
3554
  // readiness means the load settled, not that it succeeded.
3617
3555
  this.dispatchEvent(new ErrorEvent('error', {
3618
3556
  message: err instanceof Error ? err.message : String(err)
3619
3557
  }));
3620
3558
  this._onReady();
3621
- });
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`);
3622
3565
  }
3623
3566
  }
3624
3567
  /**
@@ -3753,6 +3696,8 @@ class ComponentElement extends AsyncElement {
3753
3696
  console.warn(`${this.tagName.toLowerCase()}${label} - '${entity.name}' already has a '${this._componentName}' component - component not added`);
3754
3697
  return;
3755
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
3756
3701
  this._component = entity.addComponent(this._componentName, this.getInitialComponentData());
3757
3702
  }
3758
3703
  async _addComponent() {
@@ -4015,6 +3960,9 @@ class AnimComponentElement extends ComponentElement {
4015
3960
  */
4016
3961
  _applyRootBone() {
4017
3962
  const component = this.component;
3963
+ if (!component) {
3964
+ return;
3965
+ }
4018
3966
  // A non-null root this element did not assign came through the engine API. A fresh
4019
3967
  // component starts at null, which is always reclaimable.
4020
3968
  if (component.rootBone !== null && component.rootBone !== this._managedRootBone) {
@@ -4087,7 +4035,7 @@ class AnimComponentElement extends ComponentElement {
4087
4035
  * declared `clip` selection can apply before any asset has loaded.
4088
4036
  */
4089
4037
  _assignClip(clip) {
4090
- 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);
4091
4039
  }
4092
4040
  /**
4093
4041
  * Validates a clip child and, when valid, assigns its state and starts its track resolution.
@@ -4395,8 +4343,9 @@ class AnimComponentElement extends ComponentElement {
4395
4343
  component.playing = true;
4396
4344
  }
4397
4345
  /**
4398
- * Gets the underlying PlayCanvas anim component.
4399
- * @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`.
4400
4349
  */
4401
4350
  get component() {
4402
4351
  return super.component;
@@ -4545,6 +4494,11 @@ class AnimClipElement extends AsyncElement {
4545
4494
  */
4546
4495
  _animElement = null;
4547
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();
4548
4502
  /**
4549
4503
  * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
4550
4504
  * a resume from an await abandons itself if the value has moved on, so a stale callback can
@@ -4552,19 +4506,13 @@ class AnimClipElement extends AsyncElement {
4552
4506
  * own callback.
4553
4507
  */
4554
4508
  _connectionGeneration = 0;
4555
- _errorHandle = null;
4556
4509
  /**
4557
4510
  * Incremented on every track resolution and on disconnect, and captured by a resolution when
4558
- * it starts. A resolution that resumes from an await or an asset callback abandons itself if
4559
- * 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.
4560
4514
  */
4561
4515
  _loadGeneration = 0;
4562
- /**
4563
- * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
4564
- * Held so that whatever supersedes the resolution can detach the handlers from the asset,
4565
- * rather than leave them registered until the asset settles (or forever, if it never does).
4566
- */
4567
- _loadHandle = null;
4568
4516
  _loop = true;
4569
4517
  _name = '';
4570
4518
  _speed = 1;
@@ -4604,7 +4552,7 @@ class AnimClipElement extends AsyncElement {
4604
4552
  // Invalidate any connectedCallback or track resolution still suspended on an await
4605
4553
  this._connectionGeneration++;
4606
4554
  this._loadGeneration++;
4607
- this._detachLoadHandlers();
4555
+ this._binding.cancel();
4608
4556
  // Uses the cached parent rather than a fresh lookup, since parentElement is already null
4609
4557
  // by now. The component itself is null if the whole <pc-app> is being torn down —
4610
4558
  // parents disconnect first and have already removed the component.
@@ -4622,12 +4570,6 @@ class AnimClipElement extends AsyncElement {
4622
4570
  }
4623
4571
  return animElement;
4624
4572
  }
4625
- _detachLoadHandlers() {
4626
- this._loadHandle?.off();
4627
- this._loadHandle = null;
4628
- this._errorHandle?.off();
4629
- this._errorHandle = null;
4630
- }
4631
4573
  /**
4632
4574
  * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
4633
4575
  *
@@ -4653,34 +4595,19 @@ class AnimClipElement extends AsyncElement {
4653
4595
  async _resolveTrack(animElement) {
4654
4596
  this._animElement = animElement;
4655
4597
  const generation = ++this._loadGeneration;
4656
- this._detachLoadHandlers();
4598
+ this._binding.cancel();
4657
4599
  if (this._asset) {
4658
- 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
+ });
4659
4608
  if (!asset) {
4660
4609
  this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
4661
- return;
4662
4610
  }
4663
- if (asset.loaded) {
4664
- this._extractTrack(asset, `asset '${this._asset}'`);
4665
- return;
4666
- }
4667
- // Whichever of load/error fires first detaches the other. The generation is
4668
- // re-checked even though a superseded handler is detached: the detach relies on how
4669
- // the engine's event emitter treats removal, while the check holds on its own.
4670
- this._loadHandle = asset.once('load', () => {
4671
- this._detachLoadHandlers();
4672
- if (generation !== this._loadGeneration) {
4673
- return;
4674
- }
4675
- this._extractTrack(asset, `asset '${this._asset}'`);
4676
- });
4677
- this._errorHandle = asset.once('error', () => {
4678
- this._detachLoadHandlers();
4679
- if (generation !== this._loadGeneration) {
4680
- return;
4681
- }
4682
- this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
4683
- });
4684
4611
  return;
4685
4612
  }
4686
4613
  const model = animElement.parentElement;
@@ -4787,99 +4714,321 @@ class AnimClipElement extends AsyncElement {
4787
4714
  this._loop = value;
4788
4715
  this._animElement?._onClipParamsChanged(this);
4789
4716
  }
4790
- /**
4791
- * Gets whether the clip loops.
4792
- * @returns Whether the clip loops.
4793
- */
4794
- get loop() {
4795
- return this._loop;
4717
+ /**
4718
+ * Gets whether the clip loops.
4719
+ * @returns Whether the clip loops.
4720
+ */
4721
+ get loop() {
4722
+ return this._loop;
4723
+ }
4724
+ /**
4725
+ * Sets the name of the clip: the name it is played by, and the track looked up in the
4726
+ * clip's source. Names must be unique within a `<pc-anim>` and must not contain `.`.
4727
+ * @param value - The clip name.
4728
+ */
4729
+ set name(value) {
4730
+ this._name = value;
4731
+ this._warnedInvalid = false;
4732
+ if (this._animElement) {
4733
+ this._resetReady();
4734
+ this._animElement._refreshClips();
4735
+ }
4736
+ }
4737
+ /**
4738
+ * Gets the name of the clip.
4739
+ * @returns The clip name.
4740
+ */
4741
+ get name() {
4742
+ return this._name;
4743
+ }
4744
+ /**
4745
+ * Sets the playback speed of the clip, where negative values play it backwards. Applies
4746
+ * immediately, preserving the playhead. Defaults to 1.
4747
+ * @param value - The playback speed.
4748
+ */
4749
+ set speed(value) {
4750
+ this._speed = value;
4751
+ this._animElement?._onClipParamsChanged(this);
4752
+ }
4753
+ /**
4754
+ * Gets the playback speed of the clip.
4755
+ * @returns The playback speed.
4756
+ */
4757
+ get speed() {
4758
+ return this._speed;
4759
+ }
4760
+ static get observedAttributes() {
4761
+ return ['asset', 'loop', 'name', 'speed'];
4762
+ }
4763
+ attributeChangedCallback(name, _oldValue, newValue) {
4764
+ switch (name) {
4765
+ case 'asset':
4766
+ this.asset = newValue ?? '';
4767
+ break;
4768
+ case 'loop':
4769
+ this.loop = parseBool(newValue, true);
4770
+ break;
4771
+ case 'name':
4772
+ this.name = newValue ?? '';
4773
+ break;
4774
+ case 'speed':
4775
+ this.speed = parseNumber(newValue, 1, name);
4776
+ break;
4777
+ }
4778
+ }
4779
+ }
4780
+ customElements.define('pc-anim-clip', AnimClipElement);
4781
+
4782
+ /**
4783
+ * The AudioListenerComponentElement interface provides properties and methods for manipulating
4784
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-audio-listener/ | `<pc-audio-listener>`} elements.
4785
+ * The AudioListenerComponentElement interface also inherits the properties and methods of the
4786
+ * {@link HTMLElement} interface.
4787
+ *
4788
+ * Engine component: {@link AudioListenerComponent} (`audiolistener`).
4789
+ *
4790
+ * @elementSummary The `<pc-audio-listener>` element makes its entity the point from which
4791
+ * positional sounds are heard, typically the entity holding the `<pc-camera>`. Must be a child of a
4792
+ * `<pc-entity>`, `<pc-model>` or `<pc-node>`.
4793
+ *
4794
+ * @category Components
4795
+ */
4796
+ class AudioListenerComponentElement extends ComponentElement {
4797
+ /** @ignore */
4798
+ constructor() {
4799
+ super('audiolistener');
4800
+ }
4801
+ /**
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`.
4805
+ */
4806
+ get component() {
4807
+ return super.component;
4808
+ }
4809
+ }
4810
+ customElements.define('pc-audio-listener', AudioListenerComponentElement);
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);
4796
4914
  }
4797
- /**
4798
- * Sets the name of the clip: the name it is played by, and the track looked up in the
4799
- * clip's source. Names must be unique within a `<pc-anim>` and must not contain `.`.
4800
- * @param value - The clip name.
4801
- */
4802
- set name(value) {
4803
- this._name = value;
4804
- this._warnedInvalid = false;
4805
- if (this._animElement) {
4806
- this._resetReady();
4807
- this._animElement._refreshClips();
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);
4808
4927
  }
4809
- }
4810
- /**
4811
- * Gets the name of the clip.
4812
- * @returns The clip name.
4813
- */
4814
- get name() {
4815
- return this._name;
4816
- }
4817
- /**
4818
- * Sets the playback speed of the clip, where negative values play it backwards. Applies
4819
- * immediately, preserving the playhead. Defaults to 1.
4820
- * @param value - The playback speed.
4821
- */
4822
- set speed(value) {
4823
- this._speed = value;
4824
- this._animElement?._onClipParamsChanged(this);
4825
- }
4826
- /**
4827
- * Gets the playback speed of the clip.
4828
- * @returns The playback speed.
4829
- */
4830
- get speed() {
4831
- return this._speed;
4832
- }
4833
- static get observedAttributes() {
4834
- return ['asset', 'loop', 'name', 'speed'];
4835
- }
4836
- attributeChangedCallback(name, _oldValue, newValue) {
4837
- switch (name) {
4838
- case 'asset':
4839
- this.asset = newValue ?? '';
4840
- break;
4841
- case 'loop':
4842
- this.loop = parseBool(newValue, true);
4843
- break;
4844
- case 'name':
4845
- this.name = newValue ?? '';
4846
- break;
4847
- case 'speed':
4848
- this.speed = parseNumber(newValue, 1, name);
4849
- break;
4928
+ const app = from.parentElement?.closest('pc-app');
4929
+ if (app) {
4930
+ const element = queryScope(app, nameSelector);
4931
+ if (element) {
4932
+ return element;
4933
+ }
4850
4934
  }
4851
4935
  }
4852
- }
4853
- customElements.define('pc-anim-clip', AnimClipElement);
4854
-
4936
+ return query(nameSelector);
4937
+ };
4855
4938
  /**
4856
- * The AudioListenerComponentElement interface provides properties and methods for manipulating
4857
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-audio-listener/ | `<pc-audio-listener>`} elements.
4858
- * The AudioListenerComponentElement interface also inherits the properties and methods of the
4859
- * {@link HTMLElement} interface.
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.
4860
4944
  *
4861
- * Engine component: {@link AudioListenerComponent} (`audiolistener`).
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.
4862
4961
  *
4863
- * @elementSummary The `<pc-audio-listener>` element makes its entity the point from which
4864
- * positional sounds are heard, typically the entity holding the `<pc-camera>`. Must be a child of a
4865
- * `<pc-entity>`, `<pc-model>` or `<pc-node>`.
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.
4866
4982
  *
4867
- * @category Components
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
4868
4987
  */
4869
- class AudioListenerComponentElement extends ComponentElement {
4870
- /** @ignore */
4871
- constructor() {
4872
- super('audiolistener');
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;
4873
5014
  }
4874
- /**
4875
- * Gets the underlying PlayCanvas audio listener component.
4876
- * @returns The audio listener component.
4877
- */
4878
- get component() {
4879
- return super.component;
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}`);
4880
5029
  }
4881
- }
4882
- customElements.define('pc-audio-listener', AudioListenerComponentElement);
5030
+ return entity;
5031
+ };
4883
5032
 
4884
5033
  const transitionModes = new Map([
4885
5034
  ['tint', BUTTON_TRANSITION_MODE_TINT],
@@ -4955,8 +5104,9 @@ class ButtonComponentElement extends ComponentElement {
4955
5104
  return data;
4956
5105
  }
4957
5106
  /**
4958
- * Gets the underlying PlayCanvas button component.
4959
- * @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`.
4960
5110
  */
4961
5111
  get component() {
4962
5112
  return super.component;
@@ -5414,8 +5564,9 @@ class CameraComponentElement extends ComponentElement {
5414
5564
  }
5415
5565
  }
5416
5566
  /**
5417
- * Gets the underlying PlayCanvas camera component.
5418
- * @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`.
5419
5570
  */
5420
5571
  get component() {
5421
5572
  return super.component;
@@ -5904,8 +6055,9 @@ class CollisionComponentElement extends ComponentElement {
5904
6055
  component.renderAsset = asset;
5905
6056
  }
5906
6057
  /**
5907
- * Gets the underlying PlayCanvas collision component.
5908
- * @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`.
5909
6061
  */
5910
6062
  get component() {
5911
6063
  return super.component;
@@ -6143,8 +6295,9 @@ class ElementComponentElement extends ComponentElement {
6143
6295
  return data;
6144
6296
  }
6145
6297
  /**
6146
- * Gets the underlying PlayCanvas element component.
6147
- * @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`.
6148
6301
  */
6149
6302
  get component() {
6150
6303
  return super.component;
@@ -6921,8 +7074,9 @@ class JointComponentElement extends ComponentElement {
6921
7074
  component.on('break', this._onBreak, this);
6922
7075
  }
6923
7076
  /**
6924
- * Gets the underlying PlayCanvas joint component.
6925
- * @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`.
6926
7080
  */
6927
7081
  get component() {
6928
7082
  return super.component;
@@ -7648,8 +7802,9 @@ class LayoutChildComponentElement extends ComponentElement {
7648
7802
  };
7649
7803
  }
7650
7804
  /**
7651
- * Gets the underlying PlayCanvas layout child component.
7652
- * @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`.
7653
7808
  */
7654
7809
  get component() {
7655
7810
  return super.component;
@@ -7868,8 +8023,9 @@ class LayoutGroupComponentElement extends ComponentElement {
7868
8023
  };
7869
8024
  }
7870
8025
  /**
7871
- * Gets the underlying PlayCanvas layout group component.
7872
- * @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`.
7873
8029
  */
7874
8030
  get component() {
7875
8031
  return super.component;
@@ -8158,8 +8314,9 @@ class LightComponentElement extends ComponentElement {
8158
8314
  };
8159
8315
  }
8160
8316
  /**
8161
- * Gets the underlying PlayCanvas light component.
8162
- * @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`.
8163
8320
  */
8164
8321
  get component() {
8165
8322
  return super.component;
@@ -8675,36 +8832,68 @@ customElements.define('pc-light', LightComponentElement);
8675
8832
  */
8676
8833
  class ParticleSystemComponentElement extends ComponentElement {
8677
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();
8678
8842
  /** @ignore */
8679
8843
  constructor() {
8680
8844
  super('particlesystem');
8681
8845
  }
8682
8846
  getInitialComponentData() {
8683
8847
  const asset = useAsset(this._asset);
8684
- // 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
8685
8850
  if (!asset || !asset.resource) {
8686
8851
  return {};
8687
8852
  }
8688
- if (asset.resource.colorMapAsset) {
8689
- const id = asset.resource.colorMapAsset;
8690
- const colorMapAsset = useAsset(id)?.id;
8691
- if (colorMapAsset) {
8692
- asset.resource.colorMapAsset = colorMapAsset;
8693
- }
8694
- }
8853
+ this._resolveColorMap(asset.resource);
8695
8854
  return asset.resource;
8696
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
+ }
8697
8870
  /**
8698
- * Gets the underlying PlayCanvas particle system component.
8699
- * @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`.
8700
8874
  */
8701
8875
  get component() {
8702
8876
  return super.component;
8703
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
+ }
8704
8892
  applyConfig(resource) {
8705
8893
  if (!this.component) {
8706
8894
  return;
8707
8895
  }
8896
+ this._resolveColorMap(resource);
8708
8897
  // Set all the config properties on the component
8709
8898
  for (const key in resource) {
8710
8899
  if (Object.hasOwn(resource, key)) {
@@ -8712,20 +8901,10 @@ class ParticleSystemComponentElement extends ComponentElement {
8712
8901
  }
8713
8902
  }
8714
8903
  }
8715
- async _loadAsset() {
8716
- await this.closestApp?.ready();
8717
- const asset = useAsset(this._asset);
8718
- if (!asset) {
8719
- return;
8720
- }
8721
- if (asset.loaded) {
8722
- this.applyConfig(asset.resource);
8723
- }
8724
- else {
8725
- asset.once('load', () => {
8726
- this.applyConfig(asset.resource);
8727
- });
8728
- }
8904
+ _bindConfig() {
8905
+ this._binding.bind(this._asset, {
8906
+ load: (asset) => this.applyConfig(asset.resource)
8907
+ });
8729
8908
  }
8730
8909
  /**
8731
8910
  * Sets the id of the `pc-asset` to use for the model.
@@ -8734,7 +8913,7 @@ class ParticleSystemComponentElement extends ComponentElement {
8734
8913
  set asset(value) {
8735
8914
  this._asset = value;
8736
8915
  if (this.isConnected) {
8737
- this._loadAsset();
8916
+ this._bindConfig();
8738
8917
  }
8739
8918
  }
8740
8919
  /**
@@ -8951,11 +9130,12 @@ class MaterialElement extends HTMLElement {
8951
9130
  _useSkybox = true;
8952
9131
  _useTonemap = true;
8953
9132
  /**
8954
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
8955
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
8956
- * 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.
8957
9137
  */
8958
- _mapHandles = new Map();
9138
+ _mapBindings = new Map();
8959
9139
  _updateScheduled = false;
8960
9140
  _glossConflictWarned = false;
8961
9141
  /**
@@ -9082,10 +9262,9 @@ class MaterialElement extends HTMLElement {
9082
9262
  material.update();
9083
9263
  }
9084
9264
  disconnectedCallback() {
9085
- for (const handle of this._mapHandles.values()) {
9086
- handle.off();
9265
+ for (const binding of this._mapBindings.values()) {
9266
+ binding.cancel();
9087
9267
  }
9088
- this._mapHandles.clear();
9089
9268
  if (this.material) {
9090
9269
  this.material.destroy();
9091
9270
  this.material = null;
@@ -9131,16 +9310,23 @@ class MaterialElement extends HTMLElement {
9131
9310
  'the roughness-* attributes invert gloss, so the two families contradict each other. Use one or the other.');
9132
9311
  }
9133
9312
  /**
9134
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
9135
- * 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.
9136
9316
  *
9137
9317
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
9138
9318
  * @param slot - The material property to write.
9139
9319
  */
9140
9320
  _setMap(id, slot) {
9141
- // Drop any load still pending for this slot - its texture is no longer the one we want
9142
- this._mapHandles.get(slot)?.off();
9143
- 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();
9144
9330
  if (!this.material)
9145
9331
  return;
9146
9332
  if (!id) {
@@ -9148,17 +9334,9 @@ class MaterialElement extends HTMLElement {
9148
9334
  this._scheduleUpdate();
9149
9335
  return;
9150
9336
  }
9151
- const asset = useAsset(id);
9152
- if (!asset)
9153
- return;
9154
- if (asset.loaded) {
9155
- this._applyMap(slot, asset.resource);
9156
- return;
9157
- }
9158
- this._mapHandles.set(slot, asset.once('load', () => {
9159
- this._mapHandles.delete(slot);
9160
- this._applyMap(slot, asset.resource);
9161
- }));
9337
+ binding.bind(id, {
9338
+ load: (asset) => this._applyMap(slot, asset.resource)
9339
+ });
9162
9340
  }
9163
9341
  /**
9164
9342
  * @param slot - The material property to write.
@@ -11054,8 +11232,9 @@ class RenderComponentElement extends ComponentElement {
11054
11232
  };
11055
11233
  }
11056
11234
  /**
11057
- * Gets the underlying PlayCanvas render component.
11058
- * @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`.
11059
11238
  */
11060
11239
  get component() {
11061
11240
  return super.component;
@@ -11226,8 +11405,9 @@ class RigidBodyComponentElement extends ComponentElement {
11226
11405
  };
11227
11406
  }
11228
11407
  /**
11229
- * Gets the underlying PlayCanvas rigidbody component.
11230
- * @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`.
11231
11411
  */
11232
11412
  get component() {
11233
11413
  return super.component;
@@ -11406,8 +11586,9 @@ class ScreenComponentElement extends ComponentElement {
11406
11586
  };
11407
11587
  }
11408
11588
  /**
11409
- * Gets the underlying PlayCanvas screen component.
11410
- * @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`.
11411
11592
  */
11412
11593
  get component() {
11413
11594
  return super.component;
@@ -11564,8 +11745,9 @@ class ScrollbarComponentElement extends ComponentElement {
11564
11745
  return data;
11565
11746
  }
11566
11747
  /**
11567
- * Gets the underlying PlayCanvas scrollbar component.
11568
- * @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`.
11569
11751
  */
11570
11752
  get component() {
11571
11753
  return super.component;
@@ -11741,8 +11923,9 @@ class ScrollViewComponentElement extends ComponentElement {
11741
11923
  return data;
11742
11924
  }
11743
11925
  /**
11744
- * Gets the underlying PlayCanvas scroll view component.
11745
- * @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`.
11746
11929
  */
11747
11930
  get component() {
11748
11931
  return super.component;
@@ -12894,8 +13077,9 @@ class ScriptComponentElement extends ComponentElement {
12894
13077
  super.disconnectedCallback?.();
12895
13078
  }
12896
13079
  /**
12897
- * Gets the underlying PlayCanvas script component.
12898
- * @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`.
12899
13083
  */
12900
13084
  get component() {
12901
13085
  return super.component;
@@ -12941,8 +13125,9 @@ class SoundComponentElement extends ComponentElement {
12941
13125
  };
12942
13126
  }
12943
13127
  /**
12944
- * Gets the underlying PlayCanvas sound component.
12945
- * @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`.
12946
13131
  */
12947
13132
  get component() {
12948
13133
  return super.component;
@@ -13423,8 +13608,9 @@ class GSplatComponentElement extends ComponentElement {
13423
13608
  };
13424
13609
  }
13425
13610
  /**
13426
- * Gets the underlying PlayCanvas gsplat component.
13427
- * @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`.
13428
13614
  */
13429
13615
  get component() {
13430
13616
  return super.component;
@@ -14717,31 +14903,27 @@ class SkyElement extends AsyncElement {
14717
14903
  _appElement = null;
14718
14904
  /**
14719
14905
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
14720
- * load that resumes from an await or a load callback abandons itself if the value has moved
14721
- * 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.
14722
14909
  */
14723
14910
  _loadGeneration = 0;
14724
14911
  /**
14725
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
14726
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
14727
- * 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.
14728
14914
  */
14729
- _loadHandle = null;
14915
+ _binding = new AssetBinding();
14730
14916
  connectedCallback() {
14731
14917
  this._loadSkybox();
14732
14918
  this._onReady();
14733
14919
  }
14734
14920
  disconnectedCallback() {
14735
14921
  this._loadGeneration++;
14736
- this._detachLoadHandler();
14922
+ this._binding.cancel();
14737
14923
  this._unloadSkybox();
14738
14924
  this._appElement = null;
14739
14925
  this._resetReady();
14740
14926
  }
14741
- _detachLoadHandler() {
14742
- this._loadHandle?.off();
14743
- this._loadHandle = null;
14744
- }
14745
14927
  _generateSkybox(asset) {
14746
14928
  if (!this._scene)
14747
14929
  return;
@@ -14773,7 +14955,7 @@ class SkyElement extends AsyncElement {
14773
14955
  async _loadSkybox() {
14774
14956
  // Supersede any load already in flight - only the newest load may generate the skybox
14775
14957
  const generation = ++this._loadGeneration;
14776
- this._detachLoadHandler();
14958
+ this._binding.cancel();
14777
14959
  const appElement = await this.closestApp?.ready();
14778
14960
  // The element may have been removed, or another load started, while we waited
14779
14961
  if (generation !== this._loadGeneration) {
@@ -14784,26 +14966,18 @@ class SkyElement extends AsyncElement {
14784
14966
  return;
14785
14967
  }
14786
14968
  this._appElement = appElement;
14787
- const asset = useAsset(this._asset);
14788
- 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)) {
14789
14975
  return;
14790
14976
  }
14791
14977
  this._scene = app.scene;
14792
- if (asset.loaded) {
14793
- this._generateSkybox(asset);
14794
- }
14795
- else {
14796
- // The generation is re-checked even though a superseded handler is detached: the
14797
- // detach relies on how the engine's event emitter treats removal, while the check
14798
- // holds on its own.
14799
- this._loadHandle = asset.once('load', () => {
14800
- this._loadHandle = null;
14801
- if (generation !== this._loadGeneration) {
14802
- return;
14803
- }
14804
- this._generateSkybox(asset);
14805
- });
14806
- }
14978
+ this._binding.bind(this._asset, {
14979
+ load: (asset) => this._generateSkybox(asset)
14980
+ });
14807
14981
  }
14808
14982
  _unloadSkybox() {
14809
14983
  const scene = this._scene;