@playcanvas/web-components 0.15.0 → 0.17.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 (108) hide show
  1. package/dist/app.d.cts +14 -13
  2. package/dist/app.d.ts +14 -13
  3. package/dist/async-element.d.cts +13 -13
  4. package/dist/async-element.d.ts +13 -13
  5. package/dist/components/anim-clip.d.cts +127 -0
  6. package/dist/components/anim-clip.d.ts +127 -0
  7. package/dist/components/anim-component.d.cts +235 -0
  8. package/dist/components/anim-component.d.ts +235 -0
  9. package/dist/components/{listener-component.d.cts → audio-listener-component.d.cts} +7 -5
  10. package/dist/components/{listener-component.d.ts → audio-listener-component.d.ts} +7 -5
  11. package/dist/components/button-component.d.cts +5 -1
  12. package/dist/components/button-component.d.ts +5 -1
  13. package/dist/components/camera-component.d.cts +2 -0
  14. package/dist/components/camera-component.d.ts +2 -0
  15. package/dist/components/collision-component.d.cts +5 -1
  16. package/dist/components/collision-component.d.ts +5 -1
  17. package/dist/components/component.d.cts +3 -2
  18. package/dist/components/component.d.ts +3 -2
  19. package/dist/components/element-component.d.cts +7 -0
  20. package/dist/components/element-component.d.ts +7 -0
  21. package/dist/components/gsplat-component.d.cts +2 -0
  22. package/dist/components/gsplat-component.d.ts +2 -0
  23. package/dist/components/joint-component.d.cts +2 -0
  24. package/dist/components/joint-component.d.ts +2 -0
  25. package/dist/components/{layoutchild-component.d.cts → layout-child-component.d.cts} +3 -1
  26. package/dist/components/{layoutchild-component.d.ts → layout-child-component.d.ts} +3 -1
  27. package/dist/components/{layoutgroup-component.d.cts → layout-group-component.d.cts} +3 -1
  28. package/dist/components/{layoutgroup-component.d.ts → layout-group-component.d.ts} +3 -1
  29. package/dist/components/light-component.d.cts +2 -0
  30. package/dist/components/light-component.d.ts +2 -0
  31. package/dist/components/{particlesystem-component.d.cts → particle-system-component.d.cts} +3 -1
  32. package/dist/components/{particlesystem-component.d.ts → particle-system-component.d.ts} +3 -1
  33. package/dist/components/render-component.d.cts +2 -0
  34. package/dist/components/render-component.d.ts +2 -0
  35. package/dist/components/{rigidbody-component.d.cts → rigid-body-component.d.cts} +3 -1
  36. package/dist/components/{rigidbody-component.d.ts → rigid-body-component.d.ts} +3 -1
  37. package/dist/components/screen-component.d.cts +2 -0
  38. package/dist/components/screen-component.d.ts +2 -0
  39. package/dist/components/script-component.d.cts +23 -12
  40. package/dist/components/script-component.d.ts +23 -12
  41. package/dist/components/{script.d.cts → script-instance.d.cts} +7 -7
  42. package/dist/components/{script.d.ts → script-instance.d.ts} +7 -7
  43. package/dist/components/{scrollview-component.d.cts → scroll-view-component.d.cts} +3 -1
  44. package/dist/components/{scrollview-component.d.ts → scroll-view-component.d.ts} +3 -1
  45. package/dist/components/scrollbar-component.d.cts +2 -0
  46. package/dist/components/scrollbar-component.d.ts +2 -0
  47. package/dist/components/sound-component.d.cts +3 -1
  48. package/dist/components/sound-component.d.ts +3 -1
  49. package/dist/components/sound-slot.d.cts +2 -2
  50. package/dist/components/sound-slot.d.ts +2 -2
  51. package/dist/custom-elements.json +3154 -2005
  52. package/dist/entity-base.d.cts +6 -5
  53. package/dist/entity-base.d.ts +6 -5
  54. package/dist/entity-owner.d.cts +118 -0
  55. package/dist/entity-owner.d.ts +118 -0
  56. package/dist/entity.d.cts +8 -101
  57. package/dist/entity.d.ts +8 -101
  58. package/dist/index.d.cts +26 -21
  59. package/dist/index.d.ts +26 -21
  60. package/dist/model.d.cts +67 -24
  61. package/dist/model.d.ts +67 -24
  62. package/dist/pwc.cjs +1766 -619
  63. package/dist/pwc.cjs.map +1 -1
  64. package/dist/pwc.js +1766 -619
  65. package/dist/pwc.js.map +1 -1
  66. package/dist/pwc.min.js +1 -1
  67. package/dist/pwc.min.js.map +1 -1
  68. package/dist/pwc.min.mjs +1 -1
  69. package/dist/pwc.min.mjs.map +1 -1
  70. package/dist/pwc.mjs +1762 -618
  71. package/dist/pwc.mjs.map +1 -1
  72. package/dist/vscode.html-custom-data.json +203 -89
  73. package/dist/{module.d.cts → wasm.d.cts} +6 -6
  74. package/dist/{module.d.ts → wasm.d.ts} +6 -6
  75. package/dist/web-types.json +556 -273
  76. package/package.json +7 -7
  77. package/src/app.ts +45 -35
  78. package/src/async-element.ts +14 -14
  79. package/src/components/anim-clip.ts +395 -0
  80. package/src/components/anim-component.ts +719 -0
  81. package/src/components/{listener-component.ts → audio-listener-component.ts} +8 -6
  82. package/src/components/button-component.ts +5 -1
  83. package/src/components/camera-component.ts +2 -0
  84. package/src/components/collision-component.ts +5 -1
  85. package/src/components/component.ts +4 -3
  86. package/src/components/element-component.ts +7 -0
  87. package/src/components/gsplat-component.ts +2 -0
  88. package/src/components/joint-component.ts +2 -0
  89. package/src/components/{layoutchild-component.ts → layout-child-component.ts} +4 -2
  90. package/src/components/{layoutgroup-component.ts → layout-group-component.ts} +4 -2
  91. package/src/components/light-component.ts +2 -0
  92. package/src/components/{particlesystem-component.ts → particle-system-component.ts} +4 -2
  93. package/src/components/render-component.ts +2 -0
  94. package/src/components/{rigidbody-component.ts → rigid-body-component.ts} +4 -2
  95. package/src/components/screen-component.ts +2 -0
  96. package/src/components/script-component.ts +63 -42
  97. package/src/components/{script.ts → script-instance.ts} +14 -14
  98. package/src/components/{scrollview-component.ts → scroll-view-component.ts} +4 -2
  99. package/src/components/scrollbar-component.ts +2 -0
  100. package/src/components/sound-component.ts +4 -2
  101. package/src/components/sound-slot.ts +4 -4
  102. package/src/entity-base.ts +6 -5
  103. package/src/entity-owner.ts +298 -0
  104. package/src/entity.ts +15 -270
  105. package/src/index.ts +32 -24
  106. package/src/model.ts +159 -79
  107. package/src/node.ts +9 -5
  108. package/src/{module.ts → wasm.ts} +8 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcanvas/web-components",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "author": "PlayCanvas <support@playcanvas.com>",
5
5
  "homepage": "https://playcanvas.com",
6
6
  "description": "Web Components for the PlayCanvas Engine",
@@ -91,25 +91,25 @@
91
91
  "@rollup/plugin-terser": "1.0.0",
92
92
  "@rollup/plugin-typescript": "12.3.0",
93
93
  "@tweenjs/tween.js": "25.0.0",
94
- "@vitest/coverage-v8": "4.1.10",
95
- "concurrently": "10.0.4",
94
+ "@vitest/coverage-v8": "4.1.11",
95
+ "concurrently": "10.0.5",
96
96
  "custom-element-jet-brains-integration": "1.7.0",
97
97
  "custom-element-vs-code-integration": "1.5.0",
98
98
  "earcut": "3.2.3",
99
99
  "eslint": "9.39.5",
100
100
  "globals": "17.11.0",
101
101
  "jsdom": "30.0.1",
102
- "mediabunny": "1.53.1",
102
+ "mediabunny": "1.55.2",
103
103
  "opentype.js": "2.0.0",
104
104
  "playcanvas": "2.21.4",
105
105
  "prettier": "3.9.6",
106
- "publint": "0.3.23",
107
- "rollup": "4.62.4",
106
+ "publint": "0.3.24",
107
+ "rollup": "4.62.5",
108
108
  "serve": "14.2.6",
109
109
  "tslib": "2.8.1",
110
110
  "typedoc": "0.28.20",
111
111
  "typedoc-plugin-mdn-links": "5.1.1",
112
112
  "typescript": "6.0.3",
113
- "vitest": "4.1.10"
113
+ "vitest": "4.1.11"
114
114
  }
115
115
  }
package/src/app.ts CHANGED
@@ -66,16 +66,23 @@ import {
66
66
 
67
67
  import type { AssetElement } from './asset';
68
68
  import { AsyncElement } from './async-element';
69
- import type { EntityElement } from './entity';
70
69
  import type { EntityBaseElement } from './entity-base';
70
+ import type { EntityOwnerElement } from './entity-owner';
71
71
  import { LoadingBar } from './loading-bar';
72
72
  import type { MaterialElement } from './material';
73
- import type { ModuleElement } from './module';
74
73
  import { parseBool, parseEnum, parseNumber } from './parse';
74
+ import type { WasmElement } from './wasm';
75
75
 
76
76
  /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
77
77
  const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'] as const;
78
78
 
79
+ /**
80
+ * The event types whose listeners make an element a hover target. Hover resolution walks past
81
+ * elements listening for none of them, so a silent element never swallows an ancestor's
82
+ * enter/leave pair.
83
+ */
84
+ const hoverEventTypes = ['pointerenter', 'pointerleave', 'pointermove'] as const;
85
+
79
86
  /**
80
87
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
81
88
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -163,9 +170,9 @@ class AppElement extends AsyncElement {
163
170
 
164
171
  /**
165
172
  * The elements backing this application's entities, keyed by the entity itself. Registered
166
- * by EntityElement at creation (and NodeElement at binding) and removed when an entity is
167
- * destroyed or unbound, this joins engine scene nodes back to their owning elements by
168
- * identity - never by name.
173
+ * by entity-owning elements at creation (pc-entity, and pc-model for its host) and by
174
+ * NodeElement at binding, and removed when an entity is destroyed or unbound, this joins
175
+ * engine scene nodes back to their owning elements by identity - never by name.
169
176
  */
170
177
  private _entityElements = new Map<GraphNode, EntityBaseElement>();
171
178
 
@@ -260,7 +267,7 @@ class AppElement extends AsyncElement {
260
267
  // there, leaving the element permanently unready: no canvas, no entities, no application.
261
268
  //
262
269
  // Upgrading is the fix here rather than skipping whatever has not upgraded, because a
263
- // <pc-module> is the one child that nothing else ever builds on its own behalf - skipping
270
+ // <pc-wasm> is the one child that nothing else ever builds on its own behalf - skipping
264
271
  // it would drop the wasm module the app asked for, silently and only for cloned apps.
265
272
  // Upgrading runs each descendant's connectedCallback synchronously, a few lines earlier
266
273
  // than the parser's path runs them but into the same state they see there: no application
@@ -270,11 +277,11 @@ class AppElement extends AsyncElement {
270
277
  // is left completely untouched.
271
278
  customElements.upgrade(this);
272
279
 
273
- // Get all pc-module elements that are direct children of the pc-app element
274
- const moduleElements = this.querySelectorAll<ModuleElement>(':scope > pc-module');
280
+ // Get all pc-wasm elements that are direct children of the pc-app element
281
+ const wasmElements = this.querySelectorAll<WasmElement>(':scope > pc-wasm');
275
282
 
276
283
  // Wait for all modules to load
277
- await Promise.all(Array.from(moduleElements).map((module) => module._getLoadPromise()));
284
+ await Promise.all(Array.from(wasmElements).map((element) => element._getLoadPromise()));
278
285
 
279
286
  // The element may have been removed while the modules loaded. Nothing beyond the loading
280
287
  // bar exists yet, and disconnectedCallback has already destroyed that.
@@ -462,15 +469,16 @@ class AppElement extends AsyncElement {
462
469
  materialElement._createMaterial();
463
470
  });
464
471
 
465
- // Create all entities
466
- const entityElements = this.querySelectorAll<EntityElement>('pc-entity');
467
- Array.from(entityElements).forEach((entityElement) => {
468
- entityElement._createEntity(app);
472
+ // Create all entities. pc-model joins the sweep because it owns a host entity of its
473
+ // own; its instantiated content arrives later, beneath that host.
474
+ const ownerElements = this.querySelectorAll<EntityOwnerElement>('pc-entity, pc-model');
475
+ Array.from(ownerElements).forEach((ownerElement) => {
476
+ ownerElement._createEntity(app);
469
477
  });
470
478
 
471
479
  // Build hierarchy
472
- entityElements.forEach((entityElement) => {
473
- entityElement._buildHierarchy(app);
480
+ ownerElements.forEach((ownerElement) => {
481
+ ownerElement._buildHierarchy(app);
474
482
  });
475
483
 
476
484
  // Building the hierarchy dispatched each entity's ready event synchronously, and a
@@ -602,9 +610,9 @@ class AppElement extends AsyncElement {
602
610
  // created from onpointer* attributes when their elements were first upgraded, or
603
611
  // listeners carried over from before a re-boot)
604
612
  pointerEventTypes.forEach((type) => {
605
- const anyListeners = Array.from(this.querySelectorAll<EntityBaseElement>('pc-entity, pc-node')).some(
606
- (entity) => entity._hasListeners(type)
607
- );
613
+ const anyListeners = Array.from(
614
+ this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
615
+ ).some((entity) => entity._hasListeners(type));
608
616
  if (anyListeners) {
609
617
  this._onPointerListenerAdded(type);
610
618
  }
@@ -659,10 +667,10 @@ class AppElement extends AsyncElement {
659
667
  }
660
668
 
661
669
  /**
662
- * Returns the `<pc-entity>` or `<pc-node>` element whose backing entity is `entity`, or
663
- * `null` if the entity is not fronted by an element of this application - for example, an
664
- * unbound node inside a model's instantiated hierarchy, or an entity created through the
665
- * engine API.
670
+ * Returns the `<pc-entity>`, `<pc-model>` or `<pc-node>` element whose backing entity is
671
+ * `entity`, or `null` if the entity is not fronted by an element of this application - for
672
+ * example, an unbound node inside a model's instantiated hierarchy, or an entity created
673
+ * through the engine API.
666
674
  *
667
675
  * @param entity - The entity to look up.
668
676
  * @returns The element fronting the entity, or `null`.
@@ -672,18 +680,19 @@ class AppElement extends AsyncElement {
672
680
  }
673
681
 
674
682
  /**
675
- * Resolves the element that owns a picked node: the nearest node up the parent chain -
676
- * starting with the node itself - that is fronted by a `<pc-entity>` or `<pc-node>` of this
677
- * application. A hit inside a model's instantiated hierarchy therefore resolves to the
678
- * nearest bound `<pc-node>`, or failing that the element hosting the model.
683
+ * Resolves the element that owns hover for a picked node: the nearest node up the parent
684
+ * chain - starting with the node itself - whose element listens for any of the hover event
685
+ * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
686
+ * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
687
+ * to hover rather than swallowing a listening ancestor's enter/leave pair.
679
688
  *
680
689
  * @param node - The picked node, or `null`.
681
- * @returns The owning element, or `null`.
690
+ * @returns The hover-owning element, or `null`.
682
691
  */
683
- private _elementFromNode(node: GraphNode | null): EntityBaseElement | null {
692
+ private _hoverTarget(node: GraphNode | null): EntityBaseElement | null {
684
693
  while (node !== null) {
685
694
  const element = this._entityElements.get(node);
686
- if (element) {
695
+ if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
687
696
  return element;
688
697
  }
689
698
  node = node.parent;
@@ -831,9 +840,10 @@ class AppElement extends AsyncElement {
831
840
  const node = await this._pickNode(event);
832
841
  if (token !== this._pickToken || !this._picker) return;
833
842
 
834
- // The hovered element is the nearest one up the node's parent chain, listening or not -
835
- // dispatch is gated per event type below
836
- const newHoverEntity = this._elementFromNode(node);
843
+ // The hovered element is the nearest one up the node's parent chain with a hover
844
+ // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
845
+ // type below: having any hover listener selects the target, each event needs its own.
846
+ const newHoverEntity = this._hoverTarget(node);
837
847
 
838
848
  // Handle enter/leave events
839
849
  if (this._hoveredEntity !== newHoverEntity) {
@@ -898,9 +908,9 @@ class AppElement extends AsyncElement {
898
908
  }
899
909
 
900
910
  private _onPointerListenerRemoved(type: string) {
901
- const hasListeners = Array.from(this.querySelectorAll<EntityBaseElement>('pc-entity, pc-node')).some((entity) =>
902
- entity._hasListeners(type)
903
- );
911
+ const hasListeners = Array.from(
912
+ this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
913
+ ).some((entity) => entity._hasListeners(type));
904
914
 
905
915
  if (!hasListeners && this._canvas) {
906
916
  this._hasPointerListeners[type] = false;
@@ -33,13 +33,13 @@ class AsyncElement extends HTMLElement {
33
33
  }
34
34
 
35
35
  /**
36
- * The nearest ancestor element that fronts an entity — `<pc-entity>` or `<pc-node>` or
37
- * `null` if this element has no such ancestor. The search starts at the parent, so an element
38
- * never resolves to itself.
36
+ * The nearest ancestor element that fronts an entity — `<pc-entity>`, `<pc-model>` or
37
+ * `<pc-node>` — or `null` if this element has no such ancestor. The search starts at the
38
+ * parent, so an element never resolves to itself.
39
39
  * @returns The closest entity-fronting element, or `null`.
40
40
  */
41
41
  get closestEntity(): EntityBaseElement | null {
42
- return (this.parentElement?.closest('pc-entity, pc-node') as EntityBaseElement | null) ?? null;
42
+ return (this.parentElement?.closest('pc-entity, pc-model, pc-node') as EntityBaseElement | null) ?? null;
43
43
  }
44
44
 
45
45
  /**
@@ -95,11 +95,11 @@ type AsyncElementTagName = {
95
95
 
96
96
  /**
97
97
  * Waits for the first element matching the given tag name to be fully initialized. Note that the
98
- * promise never settles if the element cannot finish initializing (for example, a `<pc-script>`
99
- * that is not a direct child of `<pc-scripts>`, or a `<pc-app>` that could not create a graphics
100
- * device — listen for its `error` event instead). A component element outside a `<pc-entity>` is
101
- * the exception: it still becomes ready, but its `component` is `null`. Either way, a misplaced
102
- * element logs a warning naming the parent it requires.
98
+ * promise never settles if the element cannot finish initializing (for example, a `<pc-script-instance>`
99
+ * that is not a direct child of `<pc-script>`, or a `<pc-app>` that could not create a graphics
100
+ * device — listen for its `error` event instead). A component element outside an entity-fronting
101
+ * element is the exception: it still becomes ready, but its `component` is `null`. Either way, a
102
+ * misplaced element logs a warning naming the parent it requires.
103
103
  * @param target - The tag name of the element to wait for (e.g. `'pc-app'`).
104
104
  * @returns A promise that resolves with the element once it's ready.
105
105
  * @example
@@ -120,11 +120,11 @@ function whenReady<K extends AsyncElementTagName>(target: K): Promise<HTMLElemen
120
120
  function whenReady<T extends AsyncElement>(target: T): Promise<T>;
121
121
  /**
122
122
  * Waits for the first element matching the given CSS selector to be fully initialized. Note that
123
- * the promise never settles if the element cannot finish initializing (for example, a `<pc-script>`
124
- * that is not a direct child of `<pc-scripts>`, or a `<pc-app>` that could not create a graphics
125
- * device — listen for its `error` event instead). A component element outside a `<pc-entity>` is
126
- * the exception: it still becomes ready, but its `component` is `null`. Either way, a misplaced
127
- * element logs a warning naming the parent it requires.
123
+ * the promise never settles if the element cannot finish initializing (for example, a `<pc-script-instance>`
124
+ * that is not a direct child of `<pc-script>`, or a `<pc-app>` that could not create a graphics
125
+ * device — listen for its `error` event instead). A component element outside an entity-fronting
126
+ * element is the exception: it still becomes ready, but its `component` is `null`. Either way, a
127
+ * misplaced element logs a warning naming the parent it requires.
128
128
  * @param target - A CSS selector matching the element to wait for (e.g. `'#my-app'`).
129
129
  * @returns A promise that resolves with the element once it's ready.
130
130
  * @example
@@ -0,0 +1,395 @@
1
+ import type { Asset, EventHandle } from 'playcanvas';
2
+ import { AnimTrack } from 'playcanvas';
3
+
4
+ import { AssetElement, useAsset } from '../asset';
5
+ import { AsyncElement } from '../async-element';
6
+ import { ModelElement } from '../model';
7
+ import { parseBool, parseNumber } from '../parse';
8
+
9
+ import type { ContainerWithAnimations } from './anim-component';
10
+ import { AnimComponentElement } from './anim-component';
11
+
12
+ /**
13
+ * The AnimClipElement interface provides properties and methods for manipulating
14
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-anim-clip/ | `<pc-anim-clip>`}
15
+ * elements. The AnimClipElement interface also inherits the properties and methods of the
16
+ * {@link HTMLElement} interface.
17
+ *
18
+ * A clip declares one named animation on its parent `<pc-anim>`. `name` is both the clip's name
19
+ * and the track looked up in the clip's source: an explicit `asset` (a `container`, an
20
+ * `animation` `.glb`, or an `animclip` JSON), or, without one, the container of the `<pc-model>`
21
+ * enclosing the parent `<pc-anim>`. A source holding a single track supplies it whatever it is
22
+ * named; in a multi-track source the track named `name` is chosen, falling back to the first
23
+ * with a warning. The element becomes ready once its resolved track is assigned.
24
+ *
25
+ * @category Components
26
+ */
27
+ class AnimClipElement extends AsyncElement {
28
+ /**
29
+ * The `<pc-anim>` this clip was adopted by, captured when the parent adopts the clip and on
30
+ * connection.
31
+ *
32
+ * `disconnectedCallback` cannot rediscover it: by the time the element is disconnected its
33
+ * `parentElement` is already `null`, so a lookup would both fail to find the component and
34
+ * emit a misleading "must be a direct child" warning for what is an ordinary removal.
35
+ */
36
+ private _animElement: AnimComponentElement | null = null;
37
+
38
+ private _asset = '';
39
+
40
+ /**
41
+ * Incremented on every connect and disconnect, and captured by connectedCallback on entry —
42
+ * a resume from an await abandons itself if the value has moved on, so a stale callback can
43
+ * neither act on a torn-down tree nor register its clip alongside a re-inserted element's
44
+ * own callback.
45
+ */
46
+ private _connectionGeneration = 0;
47
+
48
+ private _errorHandle: EventHandle | null = null;
49
+
50
+ /**
51
+ * Incremented on every track resolution and on disconnect, and captured by a resolution when
52
+ * it starts. A resolution that resumes from an await or an asset callback abandons itself if
53
+ * the value has moved on, so a superseded resolution cannot hand a stale track to the parent.
54
+ */
55
+ private _loadGeneration = 0;
56
+
57
+ /**
58
+ * The pending asset subscriptions of the current resolution, if it is waiting for its asset.
59
+ * Held so that whatever supersedes the resolution can detach the handlers from the asset,
60
+ * rather than leave them registered until the asset settles (or forever, if it never does).
61
+ */
62
+ private _loadHandle: EventHandle | null = null;
63
+
64
+ private _loop = true;
65
+
66
+ private _name = '';
67
+
68
+ private _speed = 1;
69
+
70
+ /**
71
+ * The source complaint already made — the asset id it was made for, or `''` for the
72
+ * no-asset-no-model case — so re-resolutions (host cycles, model reloads) do not repeat it.
73
+ */
74
+ private _warnedSource: string | null = null;
75
+
76
+ /**
77
+ * Whether the owning `<pc-anim>` already rejected this clip's name — its sweeps re-run on
78
+ * host cycles and must not repeat the complaint.
79
+ */
80
+ private _warnedInvalid = false;
81
+
82
+ /**
83
+ * The clip's resolved track. `null` until resolution completes, during which the owning
84
+ * `<pc-anim>` assigns the engine's placeholder track in its stead.
85
+ *
86
+ * @internal
87
+ */
88
+ _track: AnimTrack | null = null;
89
+
90
+ async connectedCallback() {
91
+ const generation = ++this._connectionGeneration;
92
+
93
+ const animElement = this.animElement;
94
+ await animElement?.ready();
95
+
96
+ // The element may have been removed (perhaps re-inserted, which runs a callback of its
97
+ // own), or its parent torn down, while we were waiting. A <pc-app> disconnects before
98
+ // its children, so by the time we resume the component can already be gone - see the
99
+ // matching guard in disconnectedCallback below.
100
+ const component = animElement ? animElement.component : null;
101
+ if (generation !== this._connectionGeneration || !animElement || !component) {
102
+ return;
103
+ }
104
+
105
+ this._animElement = animElement;
106
+ animElement._registerClip(this);
107
+ }
108
+
109
+ disconnectedCallback() {
110
+ // Invalidate any connectedCallback or track resolution still suspended on an await
111
+ this._connectionGeneration++;
112
+ this._loadGeneration++;
113
+ this._detachLoadHandlers();
114
+
115
+ // Uses the cached parent rather than a fresh lookup, since parentElement is already null
116
+ // by now. The component itself is null if the whole <pc-app> is being torn down —
117
+ // parents disconnect first and have already removed the component.
118
+ this._animElement?._unregisterClip(this);
119
+ this._animElement = null;
120
+ this._track = null;
121
+ this._resetReady();
122
+ }
123
+
124
+ protected get animElement(): AnimComponentElement | null {
125
+ const animElement = this.parentElement as AnimComponentElement;
126
+
127
+ if (!(animElement instanceof AnimComponentElement)) {
128
+ const label = this._name ? ` '${this._name}'` : '';
129
+ console.warn(`pc-anim-clip${label} must be a direct child of a pc-anim element`);
130
+ return null;
131
+ }
132
+
133
+ return animElement;
134
+ }
135
+
136
+ private _detachLoadHandlers() {
137
+ this._loadHandle?.off();
138
+ this._loadHandle = null;
139
+ this._errorHandle?.off();
140
+ this._errorHandle = null;
141
+ }
142
+
143
+ /**
144
+ * Reports a name-validation failure from the owning `<pc-anim>`, once per name value.
145
+ *
146
+ * @param message - The complaint.
147
+ * @internal
148
+ */
149
+ _markInvalid(message: string) {
150
+ if (this._warnedInvalid) {
151
+ return;
152
+ }
153
+ this._warnedInvalid = true;
154
+ console.warn(message);
155
+ }
156
+
157
+ /**
158
+ * Resolves the clip's track from its source and hands it to the owning `<pc-anim>`. Called
159
+ * by the parent whenever the clip is (re)adopted, and again when the source changes; a newer
160
+ * resolution supersedes one still in flight. The element becomes ready once the resolved
161
+ * track is assigned.
162
+ *
163
+ * @param animElement - The owning `<pc-anim>`.
164
+ * @internal
165
+ */
166
+ async _resolveTrack(animElement: AnimComponentElement) {
167
+ this._animElement = animElement;
168
+
169
+ const generation = ++this._loadGeneration;
170
+ this._detachLoadHandlers();
171
+
172
+ if (this._asset) {
173
+ const asset = useAsset(this._asset);
174
+ if (!asset) {
175
+ this._warnSource(`pc-anim-clip '${this._name}' could not find asset '${this._asset}' - clip not assigned`);
176
+ return;
177
+ }
178
+ if (asset.loaded) {
179
+ this._extractTrack(asset, `asset '${this._asset}'`);
180
+ return;
181
+ }
182
+ // Whichever of load/error fires first detaches the other. The generation is
183
+ // re-checked even though a superseded handler is detached: the detach relies on how
184
+ // the engine's event emitter treats removal, while the check holds on its own.
185
+ this._loadHandle = asset.once('load', () => {
186
+ this._detachLoadHandlers();
187
+ if (generation !== this._loadGeneration) {
188
+ return;
189
+ }
190
+ this._extractTrack(asset, `asset '${this._asset}'`);
191
+ });
192
+ this._errorHandle = asset.once('error', () => {
193
+ this._detachLoadHandlers();
194
+ if (generation !== this._loadGeneration) {
195
+ return;
196
+ }
197
+ this._warnSource(`pc-anim-clip '${this._name}' - asset '${this._asset}' failed to load - clip not assigned`);
198
+ });
199
+ return;
200
+ }
201
+
202
+ const model = animElement.parentElement;
203
+ if (!(model instanceof ModelElement)) {
204
+ this._warnSource(`pc-anim-clip '${this._name}' has no asset and no enclosing pc-model - clip not assigned`);
205
+ return;
206
+ }
207
+
208
+ await model.ready();
209
+ if (generation !== this._loadGeneration) {
210
+ return;
211
+ }
212
+
213
+ const asset = AssetElement.get(model.asset);
214
+ if (!asset?.resource) {
215
+ // The model's load failed; it already reported the error
216
+ return;
217
+ }
218
+ this._extractTrack(asset, `model '${model.asset}'`);
219
+ }
220
+
221
+ /**
222
+ * Complains about the clip's source, once per source value — resolutions re-run on host
223
+ * cycles and model reloads, and must not repeat the complaint.
224
+ */
225
+ private _warnSource(message: string) {
226
+ if (this._warnedSource === this._asset) {
227
+ return;
228
+ }
229
+ this._warnedSource = this._asset;
230
+ console.warn(message);
231
+ }
232
+
233
+ /**
234
+ * Picks the clip's track out of a loaded source asset: the track named `name`, or a lone
235
+ * track whatever it is named, or the first of several with a warning.
236
+ *
237
+ * @param asset - The loaded source asset.
238
+ * @param source - How warnings name the source.
239
+ */
240
+ private _extractTrack(asset: Asset, source: string) {
241
+ const label = `pc-anim-clip '${this._name}'`;
242
+
243
+ // Widened: the engine registers an 'animclip' handler but omits the type from the
244
+ // Asset.type union
245
+ const type: string = asset.type;
246
+
247
+ let candidates: unknown[];
248
+ switch (type) {
249
+ case 'container':
250
+ candidates = (asset.resource as ContainerWithAnimations).animations.map(
251
+ (animationAsset) => animationAsset.resource
252
+ );
253
+ break;
254
+ case 'animation':
255
+ candidates = asset.resources;
256
+ break;
257
+ case 'animclip':
258
+ candidates = [asset.resource];
259
+ break;
260
+ default:
261
+ this._warnSource(`${label} - ${source} has type '${asset.type}', expected 'container', 'animation' or 'animclip' - clip not assigned`);
262
+ return;
263
+ }
264
+
265
+ // A JSON 'animation' asset parses to the engine's legacy Animation class, which the anim
266
+ // system rejects - only real AnimTracks qualify
267
+ const tracks = candidates.filter((candidate): candidate is AnimTrack => candidate instanceof AnimTrack);
268
+ if (tracks.length === 0) {
269
+ this._warnSource(`${label} - ${source} contains no usable animation track - clip not assigned`);
270
+ return;
271
+ }
272
+
273
+ let track = tracks.find((candidate) => candidate.name === this._name);
274
+ if (!track) {
275
+ track = tracks[0];
276
+ if (tracks.length > 1) {
277
+ console.warn(
278
+ `${label} - no track named '${this._name}' in ${source} - using '${track.name}' (available: ${tracks.map((candidate) => candidate.name).join(', ')})`
279
+ );
280
+ }
281
+ }
282
+
283
+ this._track = track;
284
+ if (this._animElement?._onClipResolved(this)) {
285
+ this._onReady();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Sets the id of the `pc-asset` supplying the clip's track: a `container`, an `animation`
291
+ * `.glb`, or an `animclip` JSON. When empty, the track comes from the container of the
292
+ * `<pc-model>` enclosing the parent `<pc-anim>`.
293
+ * @param value - The asset id.
294
+ */
295
+ set asset(value: string) {
296
+ this._asset = value;
297
+ this._warnedSource = null;
298
+ if (this._animElement) {
299
+ this._resetReady();
300
+ this._track = null;
301
+ this._resolveTrack(this._animElement);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Gets the id of the `pc-asset` supplying the clip's track.
307
+ * @returns The asset id.
308
+ */
309
+ get asset() {
310
+ return this._asset;
311
+ }
312
+
313
+ /**
314
+ * Sets whether the clip loops. A non-looping clip holds its last pose when it ends — the
315
+ * engine reports no completion. Defaults to `true`.
316
+ * @param value - Whether the clip loops.
317
+ */
318
+ set loop(value: boolean) {
319
+ this._loop = value;
320
+ this._animElement?._onClipParamsChanged(this);
321
+ }
322
+
323
+ /**
324
+ * Gets whether the clip loops.
325
+ * @returns Whether the clip loops.
326
+ */
327
+ get loop() {
328
+ return this._loop;
329
+ }
330
+
331
+ /**
332
+ * Sets the name of the clip: the name it is played by, and the track looked up in the
333
+ * clip's source. Names must be unique within a `<pc-anim>` and must not contain `.`.
334
+ * @param value - The clip name.
335
+ */
336
+ set name(value: string) {
337
+ this._name = value;
338
+ this._warnedInvalid = false;
339
+ if (this._animElement) {
340
+ this._resetReady();
341
+ this._animElement._refreshClips();
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Gets the name of the clip.
347
+ * @returns The clip name.
348
+ */
349
+ get name() {
350
+ return this._name;
351
+ }
352
+
353
+ /**
354
+ * Sets the playback speed of the clip, where negative values play it backwards. Applies
355
+ * immediately, preserving the playhead. Defaults to 1.
356
+ * @param value - The playback speed.
357
+ */
358
+ set speed(value: number) {
359
+ this._speed = value;
360
+ this._animElement?._onClipParamsChanged(this);
361
+ }
362
+
363
+ /**
364
+ * Gets the playback speed of the clip.
365
+ * @returns The playback speed.
366
+ */
367
+ get speed() {
368
+ return this._speed;
369
+ }
370
+
371
+ static get observedAttributes() {
372
+ return ['asset', 'loop', 'name', 'speed'];
373
+ }
374
+
375
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
376
+ switch (name) {
377
+ case 'asset':
378
+ this.asset = newValue ?? '';
379
+ break;
380
+ case 'loop':
381
+ this.loop = parseBool(newValue, true);
382
+ break;
383
+ case 'name':
384
+ this.name = newValue ?? '';
385
+ break;
386
+ case 'speed':
387
+ this.speed = parseNumber(newValue, 1, name);
388
+ break;
389
+ }
390
+ }
391
+ }
392
+
393
+ customElements.define('pc-anim-clip', AnimClipElement);
394
+
395
+ export { AnimClipElement };