@playcanvas/web-components 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/app.d.cts +5 -99
  2. package/dist/app.d.ts +5 -99
  3. package/dist/asset-binding.d.cts +1 -0
  4. package/dist/asset-binding.d.ts +1 -0
  5. package/dist/components/anim-clip.d.cts +8 -10
  6. package/dist/components/anim-clip.d.ts +8 -10
  7. package/dist/components/anim-component.d.cts +5 -4
  8. package/dist/components/anim-component.d.ts +5 -4
  9. package/dist/components/audio-listener-component.d.cts +5 -4
  10. package/dist/components/audio-listener-component.d.ts +5 -4
  11. package/dist/components/button-component.d.cts +14 -9
  12. package/dist/components/button-component.d.ts +14 -9
  13. package/dist/components/camera-component.d.cts +5 -4
  14. package/dist/components/camera-component.d.ts +5 -4
  15. package/dist/components/collision-component.d.cts +5 -4
  16. package/dist/components/collision-component.d.ts +5 -4
  17. package/dist/components/component.d.cts +2 -2
  18. package/dist/components/component.d.ts +2 -2
  19. package/dist/components/element-component.d.cts +5 -4
  20. package/dist/components/element-component.d.ts +5 -4
  21. package/dist/components/gsplat-component.d.cts +5 -4
  22. package/dist/components/gsplat-component.d.ts +5 -4
  23. package/dist/components/joint-component.d.cts +29 -14
  24. package/dist/components/joint-component.d.ts +29 -14
  25. package/dist/components/layout-child-component.d.cts +5 -4
  26. package/dist/components/layout-child-component.d.ts +5 -4
  27. package/dist/components/layout-group-component.d.cts +5 -4
  28. package/dist/components/layout-group-component.d.ts +5 -4
  29. package/dist/components/light-component.d.cts +5 -4
  30. package/dist/components/light-component.d.ts +5 -4
  31. package/dist/components/particle-system-component.d.cts +22 -5
  32. package/dist/components/particle-system-component.d.ts +22 -5
  33. package/dist/components/render-component.d.cts +5 -4
  34. package/dist/components/render-component.d.ts +5 -4
  35. package/dist/components/rigid-body-component.d.cts +5 -4
  36. package/dist/components/rigid-body-component.d.ts +5 -4
  37. package/dist/components/screen-component.d.cts +5 -4
  38. package/dist/components/screen-component.d.ts +5 -4
  39. package/dist/components/script-component.d.cts +9 -6
  40. package/dist/components/script-component.d.ts +9 -6
  41. package/dist/components/script-instance.d.cts +14 -6
  42. package/dist/components/script-instance.d.ts +14 -6
  43. package/dist/components/scroll-view-component.d.cts +29 -16
  44. package/dist/components/scroll-view-component.d.ts +29 -16
  45. package/dist/components/scrollbar-component.d.cts +11 -7
  46. package/dist/components/scrollbar-component.d.ts +11 -7
  47. package/dist/components/sound-component.d.cts +5 -4
  48. package/dist/components/sound-component.d.ts +5 -4
  49. package/dist/custom-elements.json +59 -59
  50. package/dist/entity-reference.d.cts +13 -0
  51. package/dist/entity-reference.d.ts +13 -0
  52. package/dist/material.d.cts +8 -6
  53. package/dist/material.d.ts +8 -6
  54. package/dist/model.d.cts +8 -9
  55. package/dist/model.d.ts +8 -9
  56. package/dist/parse.d.cts +0 -3
  57. package/dist/parse.d.ts +0 -3
  58. package/dist/pointer-controller.d.cts +1 -0
  59. package/dist/pointer-controller.d.ts +1 -0
  60. package/dist/pwc.cjs +1085 -633
  61. package/dist/pwc.cjs.map +1 -1
  62. package/dist/pwc.js +1085 -633
  63. package/dist/pwc.js.map +1 -1
  64. package/dist/pwc.min.js +1 -1
  65. package/dist/pwc.min.js.map +1 -1
  66. package/dist/pwc.min.mjs +1 -1
  67. package/dist/pwc.min.mjs.map +1 -1
  68. package/dist/pwc.mjs +1086 -634
  69. package/dist/pwc.mjs.map +1 -1
  70. package/dist/sky.d.cts +6 -7
  71. package/dist/sky.d.ts +6 -7
  72. package/dist/vscode.html-custom-data.json +10 -10
  73. package/dist/web-types.json +58 -58
  74. package/package.json +3 -3
  75. package/src/app.ts +16 -416
  76. package/src/asset-binding.ts +141 -0
  77. package/src/components/anim-clip.ts +22 -45
  78. package/src/components/anim-component.ts +10 -6
  79. package/src/components/audio-listener-component.ts +6 -5
  80. package/src/components/button-component.ts +25 -15
  81. package/src/components/camera-component.ts +6 -5
  82. package/src/components/collision-component.ts +6 -5
  83. package/src/components/component.ts +6 -4
  84. package/src/components/element-component.ts +6 -5
  85. package/src/components/gsplat-component.ts +6 -5
  86. package/src/components/joint-component.ts +36 -20
  87. package/src/components/layout-child-component.ts +6 -5
  88. package/src/components/layout-group-component.ts +6 -5
  89. package/src/components/light-component.ts +6 -5
  90. package/src/components/particle-system-component.ts +56 -30
  91. package/src/components/render-component.ts +6 -5
  92. package/src/components/rigid-body-component.ts +6 -5
  93. package/src/components/screen-component.ts +6 -5
  94. package/src/components/script-component.ts +28 -17
  95. package/src/components/script-instance.ts +14 -6
  96. package/src/components/scroll-view-component.ts +56 -34
  97. package/src/components/scrollbar-component.ts +20 -13
  98. package/src/components/sound-component.ts +6 -5
  99. package/src/entity-reference.ts +237 -0
  100. package/src/material.ts +25 -29
  101. package/src/model.ts +24 -48
  102. package/src/parse.ts +0 -35
  103. package/src/pointer-controller.ts +550 -0
  104. package/src/sky.ts +19 -30
@@ -18,7 +18,7 @@ import { ComponentElement } from './component';
18
18
  *
19
19
  * @category Components
20
20
  */
21
- class SoundComponentElement extends ComponentElement {
21
+ class SoundComponentElement extends ComponentElement<SoundComponent> {
22
22
  private _distanceModel: 'exponential' | 'inverse' | 'linear' = 'linear';
23
23
 
24
24
  private _maxDistance = 10000;
@@ -51,11 +51,12 @@ class SoundComponentElement extends ComponentElement {
51
51
  }
52
52
 
53
53
  /**
54
- * Gets the underlying PlayCanvas sound component.
55
- * @returns The sound component.
54
+ * Gets the underlying PlayCanvas sound component. `null` until the element is
55
+ * ready see {@link ComponentElement.component}.
56
+ * @returns The sound component, or `null`.
56
57
  */
57
- get component(): SoundComponent {
58
- return super.component as SoundComponent;
58
+ get component(): SoundComponent | null {
59
+ return super.component;
59
60
  }
60
61
 
61
62
  /**
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Resolves entity reference strings against the document. A reference beginning with `#` is a
3
+ * document-wide selector (an element id, or any selector rooted in one); anything else is an
4
+ * entity name, resolved lexically through the entity hierarchy first and against the document
5
+ * after — never as a selector or an id.
6
+ *
7
+ * Unlike the attribute parsers in `parse.ts`, these helpers touch the DOM, and `findEntityElement`
8
+ * and `getEntity` return `null` instead of falling back to a default. They also do not warn -
9
+ * what an unresolved reference means depends on the element holding it - so elements report
10
+ * through `resolveEntity`, which takes that meaning as parameters, and `pc-script` builds its own
11
+ * message from the lower-level pieces.
12
+ */
13
+
14
+ import type { Entity } from 'playcanvas';
15
+
16
+ /**
17
+ * Runs querySelector, absorbing the SyntaxError an unparseable selector throws - references are
18
+ * arbitrary author text, so a lookup must fail to `null`, never throw.
19
+ *
20
+ * @param selector - The selector to query.
21
+ * @returns The matched element, or `null`.
22
+ */
23
+ const query = (selector: string): Element | null => {
24
+ try {
25
+ return document.querySelector(selector);
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Runs a lookup against one scope, checking the scope element itself before its subtree — a
33
+ * reference deep in a cloned prefab must be able to name the prefab's root. Absorbs the
34
+ * SyntaxError of an invalid selector like {@link query}: escaping quotes and backslashes does not
35
+ * make arbitrary text a valid CSS string (a reference containing a newline still throws), so a
36
+ * lookup must fail to `null`, never throw.
37
+ *
38
+ * @param scope - The element whose inclusive subtree to search.
39
+ * @param selector - The selector to query.
40
+ * @returns The matched element, or `null`.
41
+ */
42
+ const queryScope = (scope: Element, selector: string): Element | null => {
43
+ try {
44
+ return scope.matches(selector) ? scope : scope.querySelector(selector);
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Reads the entity a resolved element is backing, through the `entity` accessor every
52
+ * entity-fronting element exposes. `null` for no element, and for an element backing nothing.
53
+ *
54
+ * @param element - The element to read, or `null`.
55
+ * @returns The backing entity, or `null`.
56
+ */
57
+ const entityOf = (element: Element | null): Entity | null => {
58
+ return (element as { entity?: Entity } | null)?.entity ?? null;
59
+ };
60
+
61
+ /**
62
+ * The elements that front an entity: what a bare name can resolve to, and the scopes of the
63
+ * lexical name lookup.
64
+ */
65
+ const ENTITY_KINDS = ['pc-entity', 'pc-model', 'pc-node'] as const;
66
+
67
+ /**
68
+ * The entity-fronting elements as one selector, for the scope walk.
69
+ */
70
+ const ENTITY_SCOPES = ENTITY_KINDS.join(', ');
71
+
72
+ /**
73
+ * Resolves a reference string to the element it names. The grammar is closed — every reference
74
+ * has exactly one interpretation:
75
+ *
76
+ * - A reference beginning with `#` is a document-wide CSS selector — an element id (`#body`), or
77
+ * any selector rooted in one (`#hud pc-entity`). It is authoritative: the name lookup never
78
+ * runs for it, so an unusually named entity cannot shadow it.
79
+ * - Any other reference is the name of an entity-fronting element (`<pc-entity>`, `<pc-model>` or
80
+ * `<pc-node>` — for a node, the glTF node name it binds), and nothing else. A bare reference is
81
+ * never interpreted as a selector or an element id, so adding or renaming elements can never
82
+ * change which form it takes.
83
+ *
84
+ * When `from` is supplied, a name resolves lexically first: the closest entity-fronting
85
+ * ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing
86
+ * `<pc-app>`, then the document. This is what lets a `<template>` prefab reference its own
87
+ * entities by name — every clone resolves within itself before a document-wide lookup could reach
88
+ * an earlier clone — provided the prefab has a single entity-fronting root to be the enclosing
89
+ * scope.
90
+ *
91
+ * Separate from {@link getEntity} so a caller reporting a failure can tell the causes apart
92
+ * ({@link unresolvedCause} words them): nothing in the document matches the reference, or
93
+ * something matches but is not backing an entity (yet, or ever).
94
+ *
95
+ * @param ref - The reference string to resolve.
96
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
97
+ * name lookup. Omitted, the name lookup is document-wide only.
98
+ * @returns The matched element, or `null`.
99
+ * @internal
100
+ */
101
+ export const findEntityElement = (ref: string, from?: Element): Element | null => {
102
+ if (!ref) {
103
+ return null;
104
+ }
105
+
106
+ // A '#' reference is document-wide and bypasses the name lookup entirely - an entity named
107
+ // '#body' must never shadow the element whose id is 'body'.
108
+ if (ref.startsWith('#')) {
109
+ return query(ref);
110
+ }
111
+
112
+ // The name lands inside a quoted CSS string, so its quotes and backslashes are escaped -
113
+ // a name like `say "hi"` must resolve, not turn the lookup into a SyntaxError.
114
+ const escaped = ref.replace(/["\\]/g, '\\$&');
115
+ const nameSelector = ENTITY_KINDS.map(kind => `${kind}[name="${escaped}"]`).join(', ');
116
+
117
+ if (from) {
118
+ let scope = from.parentElement?.closest(ENTITY_SCOPES);
119
+ while (scope) {
120
+ const element = queryScope(scope, nameSelector);
121
+ if (element) {
122
+ return element;
123
+ }
124
+ scope = scope.parentElement?.closest(ENTITY_SCOPES);
125
+ }
126
+
127
+ const app = from.parentElement?.closest('pc-app');
128
+ if (app) {
129
+ const element = queryScope(app, nameSelector);
130
+ if (element) {
131
+ return element;
132
+ }
133
+ }
134
+ }
135
+
136
+ return query(nameSelector);
137
+ };
138
+
139
+ /**
140
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element
141
+ * (`<pc-entity>`, `<pc-model>` or `<pc-node>`). The reference is a name — resolved lexically
142
+ * through the entity hierarchy first when `from` is supplied — or a document-wide `#` selector
143
+ * ({@link findEntityElement} details the grammar and order). Returns `null` if no matching
144
+ * element (or backing entity) is found.
145
+ *
146
+ * @param ref - The reference string to resolve.
147
+ * @param from - The element resolving the reference, whose entity-fronting ancestors scope the
148
+ * name lookup. Omitted, the name lookup is document-wide only.
149
+ * @returns The resolved entity, or `null`.
150
+ * @internal
151
+ */
152
+ export const getEntity = (ref: string, from?: Element): Entity | null => {
153
+ return entityOf(findEntityElement(ref, from));
154
+ };
155
+
156
+ /**
157
+ * Describes why a non-empty reference did not resolve, for a warning. Three causes, because they
158
+ * have three different fixes: nothing matches (usually a typo), the matched element is not backing
159
+ * an entity yet (usually timing - a `pc-node` whose asset has not loaded - so resolving again
160
+ * later can work), or the matched element can never back one (the reference points at the wrong
161
+ * element, so only correcting it can). Capability is the `entity` accessor every entity-backing
162
+ * element inherits from EntityBaseElement.
163
+ *
164
+ * @param element - The element the reference matched, or `null` when nothing did.
165
+ * @returns The cause, phrased to follow `could not resolve ... -`.
166
+ * @internal
167
+ */
168
+ export const unresolvedCause = (element: Element | null): string => {
169
+ if (!element) {
170
+ return 'nothing in the document matches it';
171
+ }
172
+ const tag = `<${element.tagName.toLowerCase()}>`;
173
+ return 'entity' in element
174
+ ? `${tag} matches it but is not backing an entity yet`
175
+ : `${tag} matches it but cannot back an entity`;
176
+ };
177
+
178
+ /**
179
+ * Builds the migration pointer for a bare reference that names nothing but matches the id of an
180
+ * entity-fronting element - it was almost certainly meant as an id, so point at the form that
181
+ * expresses it, escaped so the suggestion actually parses as a selector (an id like `a:b` must
182
+ * be written `#a\:b`). Empty when the reference is already a `#` form, matches no id, or the id
183
+ * belongs to an element that could never back an entity - suggesting it would only trade this
184
+ * warning for the wrong-target one.
185
+ *
186
+ * @param ref - The unresolved reference.
187
+ * @param prefix - Text the suggested form must carry in the caller's syntax (e.g. `entity:`).
188
+ * @returns The advice sentence, or an empty string.
189
+ * @internal
190
+ */
191
+ export const idHint = (ref: string, prefix = ''): string => {
192
+ const match = !ref.startsWith('#') && document.getElementById(ref);
193
+ return match && 'entity' in match
194
+ ? `A bare reference is a name - write '${prefix}#${CSS.escape(ref)}' to reference the element with that id.`
195
+ : '';
196
+ };
197
+
198
+ /**
199
+ * Resolves a reference string to the {@link Entity} backing an entity-fronting element, scoped to
200
+ * the resolving element ({@link findEntityElement} details the order) and warning when a
201
+ * non-empty reference does not resolve - otherwise the reference fails silently, invisible
202
+ * except through the behavior it should have driven. The message names which of the three causes
203
+ * ({@link unresolvedCause}) it hit, and advises reassigning later only when that can work.
204
+ *
205
+ * An empty reference stays silent: it is the unset state of an optional attribute, and on some
206
+ * elements (`pc-joint` `entity-b`, `pc-button` `image`) a documented value of its own.
207
+ *
208
+ * @param ref - The reference string to resolve.
209
+ * @param from - The element resolving the reference; scopes the lookup and names the message.
210
+ * @param attribute - The attribute being resolved, for the message.
211
+ * @param consequence - What the unresolved reference means for the element, for the message.
212
+ * @returns The resolved entity, or `null`.
213
+ * @internal
214
+ */
215
+ export const resolveEntity = (ref: string, from: Element, attribute: string, consequence: string): Entity | null => {
216
+ if (!ref) {
217
+ return null;
218
+ }
219
+
220
+ const element = findEntityElement(ref, from);
221
+ const entity = entityOf(element);
222
+ if (!entity) {
223
+ let advice = `Assign ${attribute} again once the entity exists.`;
224
+ if (element && !('entity' in element)) {
225
+ advice = `Point ${attribute} at a pc-entity, pc-model or pc-node instead.`;
226
+ } else if (!element) {
227
+ const hint = idHint(ref);
228
+ if (hint) {
229
+ advice = hint;
230
+ }
231
+ }
232
+ console.warn(
233
+ `${from.tagName.toLowerCase()} could not resolve ${attribute} '${ref}' - ${unresolvedCause(element)} - ${consequence}. ${advice}`
234
+ );
235
+ }
236
+ return entity;
237
+ };
package/src/material.ts CHANGED
@@ -23,10 +23,10 @@ import {
23
23
  StandardMaterial,
24
24
  Vec2
25
25
  } from 'playcanvas';
26
- import type { EventHandle, Texture } from 'playcanvas';
26
+ import type { Texture } from 'playcanvas';
27
27
 
28
28
  import type { AppElement } from './app';
29
- import { useAsset } from './asset';
29
+ import { AssetBinding } from './asset-binding';
30
30
  import { parseBool, parseColor, parseEnum, parseNumber, parseVec2 } from './parse';
31
31
 
32
32
  /** The blend modes for a material. */
@@ -318,11 +318,12 @@ class MaterialElement extends HTMLElement {
318
318
  private _useTonemap = true;
319
319
 
320
320
  /**
321
- * Pending `load` handlers, one per texture slot. A slot's handler is torn down when the slot is
322
- * reassigned or the element disconnects, so a late-arriving asset can never write a texture the
323
- * element no longer wants.
321
+ * One asset binding per texture slot, created on first use and kept for the element's
322
+ * lifetime. A slot's binding is superseded when the slot is reassigned and cancelled when the
323
+ * element disconnects, so a late-arriving asset can never write a texture the element no
324
+ * longer wants.
324
325
  */
325
- private _mapHandles = new Map<TextureSlot, EventHandle>();
326
+ private _mapBindings = new Map<TextureSlot, AssetBinding>();
326
327
 
327
328
  private _updateScheduled = false;
328
329
 
@@ -461,10 +462,9 @@ class MaterialElement extends HTMLElement {
461
462
  }
462
463
 
463
464
  disconnectedCallback() {
464
- for (const handle of this._mapHandles.values()) {
465
- handle.off();
465
+ for (const binding of this._mapBindings.values()) {
466
+ binding.cancel();
466
467
  }
467
- this._mapHandles.clear();
468
468
 
469
469
  if (this.material) {
470
470
  this.material.destroy();
@@ -518,16 +518,24 @@ class MaterialElement extends HTMLElement {
518
518
  }
519
519
 
520
520
  /**
521
- * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when it
522
- * has not already. An empty id clears the slot.
521
+ * Points a texture slot at the resource of a `pc-asset`, waiting for the asset to load when
522
+ * it has not already. An empty id clears the slot; a slot keeps its current texture while
523
+ * the new asset loads, and also across a failed load - a later reload can still deliver.
523
524
  *
524
525
  * @param id - The id of the `pc-asset`, or an empty string to clear the slot.
525
526
  * @param slot - The material property to write.
526
527
  */
527
528
  private _setMap(id: string, slot: TextureSlot) {
528
- // Drop any load still pending for this slot - its texture is no longer the one we want
529
- this._mapHandles.get(slot)?.off();
530
- this._mapHandles.delete(slot);
529
+ let binding = this._mapBindings.get(slot);
530
+ if (!binding) {
531
+ binding = new AssetBinding();
532
+ this._mapBindings.set(slot, binding);
533
+ }
534
+
535
+ // Drop any load still pending for this slot - its texture is no longer the one we want.
536
+ // Cancelled here rather than left to the bind below, which the material-less and
537
+ // clear-slot returns never reach.
538
+ binding.cancel();
531
539
 
532
540
  if (!this.material) return;
533
541
 
@@ -537,21 +545,9 @@ class MaterialElement extends HTMLElement {
537
545
  return;
538
546
  }
539
547
 
540
- const asset = useAsset(id);
541
- if (!asset) return;
542
-
543
- if (asset.loaded) {
544
- this._applyMap(slot, asset.resource as Texture);
545
- return;
546
- }
547
-
548
- this._mapHandles.set(
549
- slot,
550
- asset.once('load', () => {
551
- this._mapHandles.delete(slot);
552
- this._applyMap(slot, asset.resource as Texture);
553
- })
554
- );
548
+ binding.bind(id, {
549
+ load: (asset) => this._applyMap(slot, asset.resource as Texture)
550
+ });
555
551
  }
556
552
 
557
553
  /**
package/src/model.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { ContainerResource, Entity, EventHandle } from 'playcanvas';
1
+ import type { ContainerResource, Entity } from 'playcanvas';
2
2
  import { Vec3 } from 'playcanvas';
3
3
 
4
- import { useAsset } from './asset';
4
+ import { AssetBinding } from './asset-binding';
5
5
  import { EVENT_ATTRIBUTES } from './entity-base';
6
6
  import { buildDescendantEntities, EntityOwnerElement } from './entity-owner';
7
7
  import { parseBool, parseTags, parseVec3 } from './parse';
@@ -168,20 +168,19 @@ class ModelElement extends EntityOwnerElement {
168
168
 
169
169
  /**
170
170
  * Incremented on every new load, on disconnect, and when the host entity dies, and captured
171
- * by a load when it starts. A load that resumes from an await or a load callback abandons
172
- * itself if the value has moved on, so a superseded load can neither instantiate a second
173
- * content root nor parent one under a host a newer cycle has already replaced.
171
+ * by a load when it starts. A load that resumes from an await abandons itself if the value
172
+ * has moved on, so a superseded load can neither instantiate a second content root nor
173
+ * parent one under a host a newer cycle has already replaced. The asset subscription itself
174
+ * is guarded by the binding below.
174
175
  */
175
176
  private _loadGeneration = 0;
176
177
 
177
178
  /**
178
- * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
179
- * so that whatever supersedes the load can detach the handlers from the asset, rather than
180
- * leave them registered until the asset settles (or forever, if it never does).
179
+ * The subscription to the current container asset while it is loading. Whatever supersedes
180
+ * the load a newer load, a disconnect, the host dying — cancels it, so the asset settling
181
+ * later cannot deliver to a load that no longer owns the element.
181
182
  */
182
- private _loadHandle: EventHandle | null = null;
183
-
184
- private _errorHandle: EventHandle | null = null;
183
+ private _binding = new AssetBinding();
185
184
 
186
185
  /**
187
186
  * The root entity of the instantiated model content, parented beneath the host entity.
@@ -287,7 +286,7 @@ class ModelElement extends EntityOwnerElement {
287
286
  // resets the element. The generation guard comes first so a load suspended on an await
288
287
  // cannot resume against the torn-down element.
289
288
  this._loadGeneration++;
290
- this._detachLoadHandlers();
289
+ this._binding.cancel();
291
290
  this._entity?.destroy();
292
291
  }
293
292
 
@@ -308,18 +307,11 @@ class ModelElement extends EntityOwnerElement {
308
307
  */
309
308
  protected override _onEntityDestroy(entity: Entity) {
310
309
  this._loadGeneration++;
311
- this._detachLoadHandlers();
310
+ this._binding.cancel();
312
311
  this._contentEntity = null;
313
312
  super._onEntityDestroy(entity);
314
313
  }
315
314
 
316
- private _detachLoadHandlers() {
317
- this._loadHandle?.off();
318
- this._loadHandle = null;
319
- this._errorHandle?.off();
320
- this._errorHandle = null;
321
- }
322
-
323
315
  /**
324
316
  * Resolves readiness and dispatches the `load` event. Called once the instantiated content
325
317
  * has been parented beneath the host — the host itself is already in the scene graph by
@@ -350,7 +342,7 @@ class ModelElement extends EntityOwnerElement {
350
342
 
351
343
  // Supersede any load already in flight - only the newest load may instantiate
352
344
  const generation = ++this._loadGeneration;
353
- this._detachLoadHandlers();
345
+ this._binding.cancel();
354
346
 
355
347
  // Re-arm readiness so a waiter obtained after an asset change resolves against the new
356
348
  // content. A no-op on first connection, where readiness is still pending.
@@ -383,32 +375,11 @@ class ModelElement extends EntityOwnerElement {
383
375
  return;
384
376
  }
385
377
 
386
- const asset = useAsset(this._asset);
387
- if (!asset) {
388
- // A non-empty id that resolves to nothing is a dead end - say so rather than staying
389
- // silently pending.
390
- console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
391
- return;
392
- }
393
-
394
- if (asset.loaded) {
395
- this._instantiate(asset.resource as ContainerResource);
396
- } else {
397
- // The generation is re-checked even though a superseded handler is detached: the
398
- // detach relies on how the engine's event emitter treats removal, while the check
399
- // holds on its own. Whichever of load/error fires first detaches the other.
400
- this._loadHandle = asset.once('load', () => {
401
- this._detachLoadHandlers();
402
- if (generation !== this._loadGeneration) {
403
- return;
404
- }
405
- this._instantiate(asset.resource as ContainerResource);
406
- });
407
- this._errorHandle = asset.once('error', (err: string | Error) => {
408
- this._detachLoadHandlers();
409
- if (generation !== this._loadGeneration) {
410
- return;
411
- }
378
+ // Every path that moves _loadGeneration also rebinds or cancels the binding, so a
379
+ // delivery below is always current - no generation re-check needed in the callbacks.
380
+ const asset = this._binding.bind(this._asset, {
381
+ load: ({ resource }) => this._instantiate(resource as ContainerResource),
382
+ error: (err) => {
412
383
  // A failed load settles readiness with a null contentEntity, mirroring pc-asset:
413
384
  // readiness means the load settled, not that it succeeded.
414
385
  this.dispatchEvent(
@@ -417,7 +388,12 @@ class ModelElement extends EntityOwnerElement {
417
388
  })
418
389
  );
419
390
  this._onReady();
420
- });
391
+ }
392
+ });
393
+ if (!asset) {
394
+ // A non-empty id that resolves to nothing is a dead end - say so rather than staying
395
+ // silently pending.
396
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
421
397
  }
422
398
  }
423
399
 
package/src/parse.ts CHANGED
@@ -11,12 +11,8 @@
11
11
  * shared frozen constants (`Vec3.ZERO`, `Color.WHITE`) as defaults.
12
12
  * - `parseBool` and `parseTags` take no attribute name, because every value is valid for them and
13
13
  * so they never warn.
14
- *
15
- * `getEntity` is the exception: it resolves a reference to a live entity rather than parsing a
16
- * literal, and returns `null` instead of falling back to a default.
17
14
  */
18
15
 
19
- import type { Entity } from 'playcanvas';
20
16
  import { Color, Quat, Vec2, Vec3, Vec4 } from 'playcanvas';
21
17
 
22
18
  import { CSS_COLORS } from './colors';
@@ -322,34 +318,3 @@ export const parseVec4 = <T extends Vec4 | null>(
322
318
  }
323
319
  return new Vec4(components);
324
320
  };
325
-
326
- /**
327
- * Resolves a reference string to the {@link Entity} backing a `<pc-entity>` element. The reference
328
- * can be a CSS selector (e.g. `#my-id`, `pc-entity[name="Foo"]`), a bare element id, or a bare
329
- * entity name. Returns `null` if no matching element (or backing entity) is found.
330
- *
331
- * @param ref - The reference string to resolve.
332
- * @returns The resolved entity, or `null`.
333
- * @internal
334
- */
335
- export const getEntity = (ref: string): Entity | null => {
336
- if (!ref) {
337
- return null;
338
- }
339
-
340
- let element: Element | null = null;
341
-
342
- // Try the reference as a CSS selector. An invalid selector (e.g. a bare name containing
343
- // spaces) throws, in which case we fall back to id/name lookups below.
344
- try {
345
- element = document.querySelector(ref);
346
- } catch {
347
- element = null;
348
- }
349
-
350
- if (!element) {
351
- element = document.getElementById(ref) ?? document.querySelector(`pc-entity[name="${ref}"]`);
352
- }
353
-
354
- return (element as { entity?: Entity } | null)?.entity ?? null;
355
- };