@playcanvas/web-components 0.11.1 → 0.12.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.
package/src/model.ts CHANGED
@@ -8,6 +8,21 @@ import { AsyncElement } from './async-element';
8
8
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
9
9
  * The ModelElement interface also inherits the properties and methods of the
10
10
  * {@link HTMLElement} interface.
11
+ *
12
+ * The element becomes ready once its container asset has loaded and the instantiated hierarchy has
13
+ * been added to the scene — `entity` is non-null by then. A failed load also settles readiness,
14
+ * with `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen
15
+ * for `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness
16
+ * and instantiates anew, so a `ready()` obtained after the change resolves against the new
17
+ * hierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never
18
+ * becomes ready.
19
+ *
20
+ * @fires {Event} load - Fired each time a container asset finishes instantiating, including
21
+ * re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a
22
+ * capture-phase listener on an ancestor.
23
+ * @fires {ErrorEvent} error - Fired when the container asset fails to load, with the engine's
24
+ * error in `message`. Does not bubble. The element still becomes ready — readiness means the load
25
+ * settled, not that it succeeded.
11
26
  */
12
27
  class ModelElement extends AsyncElement {
13
28
  private _asset = '';
@@ -23,12 +38,14 @@ class ModelElement extends AsyncElement {
23
38
  private _loadGeneration = 0;
24
39
 
25
40
  /**
26
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
27
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
28
- * than leave it registered until the asset loads (or forever, if it never does).
41
+ * The pending asset subscriptions of the current load, if it is waiting for its asset. Held
42
+ * so that whatever supersedes the load can detach the handlers from the asset, rather than
43
+ * leave them registered until the asset settles (or forever, if it never does).
29
44
  */
30
45
  private _loadHandle: EventHandle | null = null;
31
46
 
47
+ private _errorHandle: EventHandle | null = null;
48
+
32
49
  /**
33
50
  * The root entity of the instantiated model. `null` until the container asset has loaded
34
51
  * and been instantiated, and again once the element has been removed from the document.
@@ -39,20 +56,39 @@ class ModelElement extends AsyncElement {
39
56
  }
40
57
 
41
58
  connectedCallback() {
59
+ // A model outside an application is inert and never becomes ready, so awaiting it hangs.
60
+ // Warn rather than fail silently, naming the parent it requires, as every other misplaced
61
+ // element does.
62
+ if (!this.closestApp) {
63
+ const label = this._asset ? ` '${this._asset}'` : '';
64
+ console.warn(`pc-model${label} must be a descendant of pc-app - model not created`);
65
+ return;
66
+ }
42
67
  this._loadModel();
43
- this._onReady();
44
68
  }
45
69
 
46
70
  disconnectedCallback() {
47
71
  this._loadGeneration++;
48
- this._detachLoadHandler();
72
+ this._detachLoadHandlers();
49
73
  this._unloadModel();
50
74
  this._resetReady();
51
75
  }
52
76
 
53
- private _detachLoadHandler() {
77
+ private _detachLoadHandlers() {
54
78
  this._loadHandle?.off();
55
79
  this._loadHandle = null;
80
+ this._errorHandle?.off();
81
+ this._errorHandle = null;
82
+ }
83
+
84
+ /**
85
+ * Resolves readiness and dispatches the `load` event. Called once the instantiated hierarchy
86
+ * has been parented — readiness means "in the scene graph", matching `pc-entity`, so a ready
87
+ * model's entity always has world transforms.
88
+ */
89
+ private _announceLoad() {
90
+ this._onReady();
91
+ this.dispatchEvent(new Event('load'));
56
92
  }
57
93
 
58
94
  private _instantiate(container: ContainerResource) {
@@ -78,6 +114,7 @@ class ModelElement extends AsyncElement {
78
114
  return;
79
115
  }
80
116
  parentEntityElement.entity!.addChild(entity);
117
+ this._announceLoad();
81
118
  });
82
119
  } else {
83
120
  const appElement = this.closestApp;
@@ -87,6 +124,7 @@ class ModelElement extends AsyncElement {
87
124
  return;
88
125
  }
89
126
  appElement.app!.root.addChild(entity);
127
+ this._announceLoad();
90
128
  });
91
129
  }
92
130
  }
@@ -97,19 +135,35 @@ class ModelElement extends AsyncElement {
97
135
 
98
136
  // Supersede any load already in flight - only the newest load may instantiate
99
137
  const generation = ++this._loadGeneration;
100
- this._detachLoadHandler();
138
+ this._detachLoadHandlers();
101
139
 
102
- const appElement = await this.closestApp?.ready();
140
+ // Re-arm readiness so a waiter obtained after an asset change resolves against the new
141
+ // hierarchy. A no-op on first connection, where readiness is still pending.
142
+ this._resetReady();
143
+
144
+ const appElement = this.closestApp;
145
+ if (!appElement) {
146
+ // Outside pc-app; connectedCallback already warned. Reached through the asset setter.
147
+ return;
148
+ }
149
+
150
+ await appElement.ready();
103
151
 
104
152
  // The element may have been removed, or another load started, while we waited
105
153
  if (generation !== this._loadGeneration) {
106
154
  return;
107
155
  }
108
156
 
109
- const app = appElement?.app;
157
+ const app = appElement.app;
110
158
 
111
159
  const asset = AssetElement.get(this._asset);
112
160
  if (!asset) {
161
+ // An empty id is a legitimate transient (the asset may be assigned later); a
162
+ // non-empty one that resolves to nothing is a dead end - say so rather than staying
163
+ // silently pending.
164
+ if (this._asset) {
165
+ console.warn(`pc-model could not find asset '${this._asset}' - model not created`);
166
+ }
113
167
  return;
114
168
  }
115
169
 
@@ -118,14 +172,28 @@ class ModelElement extends AsyncElement {
118
172
  } else {
119
173
  // The generation is re-checked even though a superseded handler is detached: the
120
174
  // detach relies on how the engine's event emitter treats removal, while the check
121
- // holds on its own.
175
+ // holds on its own. Whichever of load/error fires first detaches the other.
122
176
  this._loadHandle = asset.once('load', () => {
123
- this._loadHandle = null;
177
+ this._detachLoadHandlers();
124
178
  if (generation !== this._loadGeneration) {
125
179
  return;
126
180
  }
127
181
  this._instantiate(asset.resource as ContainerResource);
128
182
  });
183
+ this._errorHandle = asset.once('error', (err: string | Error) => {
184
+ this._detachLoadHandlers();
185
+ if (generation !== this._loadGeneration) {
186
+ return;
187
+ }
188
+ // A failed load settles readiness with a null entity, mirroring pc-asset:
189
+ // readiness means the load settled, not that it succeeded.
190
+ this.dispatchEvent(
191
+ new ErrorEvent('error', {
192
+ message: err instanceof Error ? err.message : String(err)
193
+ })
194
+ );
195
+ this._onReady();
196
+ });
129
197
  app!.assets.load(asset);
130
198
  }
131
199
  }