@playcanvas/web-components 0.13.0 → 0.14.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 (51) hide show
  1. package/dist/app.d.cts +35 -0
  2. package/dist/app.d.ts +35 -0
  3. package/dist/asset.d.cts +7 -1
  4. package/dist/asset.d.ts +7 -1
  5. package/dist/colors.d.cts +1 -1
  6. package/dist/colors.d.ts +1 -1
  7. package/dist/custom-elements.json +114 -1
  8. package/dist/entity-base.d.cts +1 -7
  9. package/dist/entity-base.d.ts +1 -7
  10. package/dist/index.d.cts +2 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/loading-bar.d.cts +1 -35
  13. package/dist/loading-bar.d.ts +1 -35
  14. package/dist/material.d.cts +13 -0
  15. package/dist/material.d.ts +13 -0
  16. package/dist/model.d.cts +71 -0
  17. package/dist/model.d.ts +71 -0
  18. package/dist/node.d.cts +55 -0
  19. package/dist/node.d.ts +55 -0
  20. package/dist/parse.d.cts +1 -130
  21. package/dist/parse.d.ts +1 -130
  22. package/dist/pwc.cjs +506 -54
  23. package/dist/pwc.cjs.map +1 -1
  24. package/dist/pwc.js +506 -54
  25. package/dist/pwc.js.map +1 -1
  26. package/dist/pwc.min.js +1 -1
  27. package/dist/pwc.min.js.map +1 -1
  28. package/dist/pwc.min.mjs +1 -1
  29. package/dist/pwc.min.mjs.map +1 -1
  30. package/dist/pwc.mjs +506 -54
  31. package/dist/pwc.mjs.map +1 -1
  32. package/dist/vscode.html-custom-data.json +12 -2
  33. package/dist/web-types.json +22 -3
  34. package/package.json +3 -3
  35. package/src/app.ts +92 -19
  36. package/src/asset.ts +34 -2
  37. package/src/colors.ts +5 -0
  38. package/src/components/button-component.ts +7 -7
  39. package/src/components/element-component.ts +7 -7
  40. package/src/components/gsplat-component.ts +3 -3
  41. package/src/components/particlesystem-component.ts +7 -8
  42. package/src/components/script-component.ts +2 -2
  43. package/src/components/sound-slot.ts +2 -2
  44. package/src/entity-base.ts +3 -3
  45. package/src/index.ts +2 -0
  46. package/src/loading-bar.ts +2 -3
  47. package/src/material.ts +31 -2
  48. package/src/model.ts +158 -5
  49. package/src/node.ts +277 -2
  50. package/src/parse.ts +11 -1
  51. package/src/sky.ts +2 -3
package/dist/pwc.mjs CHANGED
@@ -182,6 +182,7 @@ const REMOVAL_DELAY_MS = 250;
182
182
  * All styling is inline, so the library injects no stylesheet. The colors and height resolve CSS
183
183
  * custom properties — `--pc-loading-bar-color`, `--pc-loading-bar-background` and
184
184
  * `--pc-loading-bar-height` — so a page can theme the bar from `pc-app` or `:root`.
185
+ * @internal
185
186
  */
186
187
  class LoadingBar {
187
188
  _track;
@@ -280,6 +281,11 @@ class LoadingBar {
280
281
  }
281
282
  }
282
283
 
284
+ /**
285
+ * The CSS color keywords, lowercase name to hex value. Read by `parseColor` to accept color
286
+ * names as attribute values.
287
+ * @internal
288
+ */
283
289
  const CSS_COLORS = {
284
290
  aliceblue: '#f0f8ff',
285
291
  antiquewhite: '#faebd7',
@@ -455,7 +461,7 @@ const CSS_COLORS = {
455
461
  * @param value - The value to split.
456
462
  * @param count - The required number of components.
457
463
  * @returns The parsed components, or `null`.
458
- * @ignore
464
+ * @internal
459
465
  */
460
466
  const parseComponents = (value, count) => {
461
467
  const components = value.trim().split(/\s+/).map(Number);
@@ -486,6 +492,7 @@ const cloneDefault = (value) => {
486
492
  * @param value - The attribute value to parse (`null` when the attribute is absent).
487
493
  * @param defaultValue - The value to use when the attribute is absent or removed.
488
494
  * @returns The parsed boolean.
495
+ * @internal
489
496
  */
490
497
  const parseBool = (value, defaultValue) => {
491
498
  return value === null ? defaultValue : value !== 'false';
@@ -501,6 +508,7 @@ const parseBool = (value, defaultValue) => {
501
508
  * @param defaultValue - The value to use when the attribute is absent or invalid.
502
509
  * @param attribute - The attribute name, used in the warning message.
503
510
  * @returns The parsed Color object.
511
+ * @internal
504
512
  */
505
513
  const parseColor = (value, defaultValue, attribute) => {
506
514
  if (value === null) {
@@ -542,6 +550,7 @@ const parseColor = (value, defaultValue, attribute) => {
542
550
  * @param defaultValue - The value to use when the attribute is absent or invalid.
543
551
  * @param attribute - The attribute name, used in the warning message.
544
552
  * @returns The resolved enum name.
553
+ * @internal
545
554
  */
546
555
  const parseEnum = (value, valid, defaultValue, attribute) => {
547
556
  if (value === null) {
@@ -563,6 +572,7 @@ const parseEnum = (value, valid, defaultValue, attribute) => {
563
572
  * @param defaultValue - The value to use when the attribute is absent or invalid.
564
573
  * @param attribute - The attribute name, used in the warning message.
565
574
  * @returns The parsed number.
575
+ * @internal
566
576
  */
567
577
  const parseNumber = (value, defaultValue, attribute) => {
568
578
  if (value === null) {
@@ -585,6 +595,7 @@ const parseNumber = (value, defaultValue, attribute) => {
585
595
  * @param defaultValue - The value to use when the attribute is absent or invalid.
586
596
  * @param attribute - The attribute name, used in the warning message.
587
597
  * @returns The parsed Quat object.
598
+ * @internal
588
599
  */
589
600
  const parseQuat = (value, defaultValue, attribute) => {
590
601
  if (value === null) {
@@ -608,6 +619,7 @@ const parseQuat = (value, defaultValue, attribute) => {
608
619
  * @param value - The attribute value to parse (`null` when the attribute is absent).
609
620
  * @param defaultValue - The value to use when the attribute is absent or removed.
610
621
  * @returns The parsed tag names.
622
+ * @internal
611
623
  */
612
624
  const parseTags = (value, defaultValue = []) => {
613
625
  if (value === null) {
@@ -629,6 +641,7 @@ const parseTags = (value, defaultValue = []) => {
629
641
  * @param defaultValue - The value to use when the attribute is absent or invalid.
630
642
  * @param attribute - The attribute name, used in the warning message.
631
643
  * @returns The parsed Vec2 object.
644
+ * @internal
632
645
  */
633
646
  const parseVec2 = (value, defaultValue, attribute) => {
634
647
  if (value === null) {
@@ -650,6 +663,7 @@ const parseVec2 = (value, defaultValue, attribute) => {
650
663
  * @param defaultValue - The value to use when the attribute is absent or invalid.
651
664
  * @param attribute - The attribute name, used in the warning message.
652
665
  * @returns The parsed Vec3 object.
666
+ * @internal
653
667
  */
654
668
  const parseVec3 = (value, defaultValue, attribute) => {
655
669
  if (value === null) {
@@ -671,6 +685,7 @@ const parseVec3 = (value, defaultValue, attribute) => {
671
685
  * @param defaultValue - The value to use when the attribute is absent or invalid.
672
686
  * @param attribute - The attribute name, used in the warning message.
673
687
  * @returns The parsed Vec4 object.
688
+ * @internal
674
689
  */
675
690
  const parseVec4 = (value, defaultValue, attribute) => {
676
691
  if (value === null) {
@@ -690,6 +705,7 @@ const parseVec4 = (value, defaultValue, attribute) => {
690
705
  *
691
706
  * @param ref - The reference string to resolve.
692
707
  * @returns The resolved entity, or `null`.
708
+ * @internal
693
709
  */
694
710
  const getEntity = (ref) => {
695
711
  if (!ref) {
@@ -1248,21 +1264,63 @@ class AppElement extends AsyncElement {
1248
1264
  }
1249
1265
  return null;
1250
1266
  }
1251
- // New helper to convert CSS coordinates to canvas (picker) coordinates
1252
- _getPickerCoordinates(event) {
1253
- // Get the canvas' bounding rectangle in CSS pixels.
1254
- const canvasRect = this._canvas.getBoundingClientRect();
1255
- // Compute scale factors based on canvas actual resolution vs. its CSS display size.
1256
- const scaleX = this._canvas.width / canvasRect.width;
1257
- const scaleY = this._canvas.height / canvasRect.height;
1258
- // Convert the client coordinates accordingly.
1259
- const x = (event.clientX - canvasRect.left) * scaleX;
1260
- const y = (event.clientY - canvasRect.top) * scaleY;
1261
- return { x, y };
1267
+ /**
1268
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1269
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1270
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1271
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1272
+ * coordinates correspond to no real geometry.
1273
+ *
1274
+ * @param event - The pointer event to convert.
1275
+ * @param canvas - The canvas the event was dispatched on.
1276
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
1277
+ */
1278
+ _getPickerCoordinates(event, canvas) {
1279
+ const canvasRect = canvas.getBoundingClientRect();
1280
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
1281
+ return { x: event.clientX, y: event.clientY, mapped: false };
1282
+ }
1283
+ const scaleX = canvas.width / canvasRect.width;
1284
+ const scaleY = canvas.height / canvasRect.height;
1285
+ return {
1286
+ x: (event.clientX - canvasRect.left) * scaleX,
1287
+ y: (event.clientY - canvasRect.top) * scaleY,
1288
+ mapped: true
1289
+ };
1290
+ }
1291
+ /**
1292
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
1293
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1294
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1295
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1296
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1297
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1298
+ * holds nothing there.
1299
+ *
1300
+ * @param camera - The camera to test.
1301
+ * @param x - The x coordinate, in buffer space.
1302
+ * @param y - The y coordinate, in buffer space.
1303
+ * @param canvas - The canvas the coordinates are relative to.
1304
+ * @returns Whether the camera's viewport contains the point.
1305
+ */
1306
+ _cameraContains(camera, x, y, canvas) {
1307
+ const rect = camera.rect;
1308
+ const left = rect.x * canvas.width;
1309
+ const bottom = (1 - rect.y) * canvas.height;
1310
+ const top = bottom - rect.w * canvas.height;
1311
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1262
1312
  }
1263
1313
  /**
1264
1314
  * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1265
1315
  *
1316
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1317
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1318
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1319
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
1320
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1321
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1322
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
1323
+ *
1266
1324
  * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1267
1325
  * supported on WebGPU, where it returns an empty selection rather than failing - which
1268
1326
  * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
@@ -1272,16 +1330,40 @@ class AppElement extends AsyncElement {
1272
1330
  * @returns The graph node under the pointer, or `null` if nothing was hit.
1273
1331
  */
1274
1332
  async _pickNode(event) {
1275
- const camera = this.app.root.findComponent('camera');
1276
- if (!camera)
1277
- return null;
1278
- const { x, y } = this._getPickerCoordinates(event);
1279
- this._picker.prepare(camera, this.app.scene);
1280
- const selection = await this._picker.getSelectionAsync(x, y);
1281
- if (selection.length === 0)
1333
+ const app = this.app;
1334
+ const picker = this._picker;
1335
+ const canvas = this._canvas;
1336
+ if (!app || !picker || !canvas)
1282
1337
  return null;
1283
- const item = selection[0];
1284
- return item instanceof MeshInstance ? item.node : item.entity;
1338
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1339
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
1340
+ // renders last and sits on top. Read through .at() because a pick handler may remove
1341
+ // cameras while an earlier iteration's read back is in flight.
1342
+ const cameras = app.systems.camera?.cameras ?? [];
1343
+ for (let i = cameras.length - 1; i >= 0; i--) {
1344
+ const camera = cameras.at(i);
1345
+ // A camera rendering to a texture is not on the canvas.
1346
+ if (!camera || camera.renderTarget)
1347
+ continue;
1348
+ // Coordinates that could not be mapped cannot be tested for containment.
1349
+ if (mapped && !this._cameraContains(camera, x, y, canvas))
1350
+ continue;
1351
+ picker.prepare(camera, app.scene);
1352
+ const selection = await picker.getSelectionAsync(x, y);
1353
+ // The element may have disconnected while the read back was in flight.
1354
+ if (!this._picker || !this.app)
1355
+ return null;
1356
+ if (selection.length > 0) {
1357
+ const item = selection[0];
1358
+ return item instanceof MeshInstance ? item.node : item.entity;
1359
+ }
1360
+ // Nothing hit. A camera that clears the color buffer paints its background over
1361
+ // everything beneath it, so the miss is final; one that does not is an overlay
1362
+ // that the cameras beneath show through, so they get their turn.
1363
+ if (camera.clearColorBuffer)
1364
+ return null;
1365
+ }
1366
+ return null;
1285
1367
  }
1286
1368
  async _onPointerMove(event) {
1287
1369
  if (!this._picker || !this.app)
@@ -1528,7 +1610,7 @@ customElements.define('pc-app', AppElement);
1528
1610
  /**
1529
1611
  * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1530
1612
  * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1531
- * @ignore
1613
+ * @internal
1532
1614
  */
1533
1615
  const POINTER_ATTRIBUTES = [
1534
1616
  'onpointerenter',
@@ -2225,6 +2307,10 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
2225
2307
  * immediately unless `lazy`. A `pc-asset` must be a direct child of `pc-app` — elements placed
2226
2308
  * elsewhere, or with an unsupported asset type, never become ready.
2227
2309
  *
2310
+ * A `lazy` asset loads on first use: the first time any element resolves it by `id` — a model,
2311
+ * a material map, a sky, a script `asset:` reference — or when the `lazy` attribute is removed,
2312
+ * whichever comes first. Until then it stays registered and unloaded.
2313
+ *
2228
2314
  * For `texture` and `textureatlas` assets, the texture options (`address-u`, `address-v`,
2229
2315
  * `min-filter`, `mag-filter`, `anisotropy`, `mipmaps`, `srgb`, `flip-y`) apply when the texture is
2230
2316
  * created and — like `lazy` — are observed: changing one updates a texture that has already
@@ -2424,7 +2510,9 @@ class AssetElement extends AsyncElement {
2424
2510
  if (type === 'sprite') {
2425
2511
  data = data ?? {};
2426
2512
  // Resolve the referenced texture atlas to its (numeric) asset id. The atlas must be
2427
- // declared before the sprite so its asset already exists in the registry.
2513
+ // declared before the sprite so its asset already exists in the registry. Resolved
2514
+ // with get, not useAsset: creation-time wiring is not a use, and the engine's
2515
+ // sprite handler loads the atlas when the sprite itself loads.
2428
2516
  const atlas = this.getAttribute('atlas') ?? data.textureAtlasAsset;
2429
2517
  if (typeof atlas === 'string') {
2430
2518
  const atlasAsset = AssetElement.get(atlas);
@@ -2585,13 +2673,18 @@ class AssetElement extends AsyncElement {
2585
2673
  return this._flipY;
2586
2674
  }
2587
2675
  /**
2588
- * Sets whether the asset should be loaded lazily.
2676
+ * Sets whether the asset should be loaded lazily. A lazy asset is registered without being
2677
+ * loaded; it loads on first use - the first time any element resolves it by `id` - or when
2678
+ * this flag is cleared on a registered asset, whichever comes first.
2589
2679
  * @param value - The lazy loading flag.
2590
2680
  */
2591
2681
  set lazy(value) {
2592
2682
  this._lazy = value;
2593
2683
  if (this.asset) {
2594
2684
  this.asset.preload = !value;
2685
+ if (!value) {
2686
+ this.asset.registry?.load(this.asset);
2687
+ }
2595
2688
  }
2596
2689
  }
2597
2690
  /**
@@ -2775,6 +2868,26 @@ class AssetElement extends AsyncElement {
2775
2868
  }
2776
2869
  }
2777
2870
  customElements.define('pc-asset', AssetElement);
2871
+ /**
2872
+ * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
2873
+ * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
2874
+ * resolves its references here, which is what makes `lazy` mean load on first use without any
2875
+ * consumer having to remember the load. The load is asynchronous - callers observe the asset's
2876
+ * `load` event for the resource.
2877
+ *
2878
+ * @param id - The `id` of the `<pc-asset>` element.
2879
+ * @returns The asset, or `undefined`.
2880
+ * @internal
2881
+ */
2882
+ const useAsset = (id) => {
2883
+ const asset = AssetElement.get(id);
2884
+ // load() ignores an asset that is already loaded or loading, so repeated resolution
2885
+ // costs nothing.
2886
+ if (asset) {
2887
+ asset.registry?.load(asset);
2888
+ }
2889
+ return asset;
2890
+ };
2778
2891
 
2779
2892
  /**
2780
2893
  * Represents a component in the PlayCanvas engine.
@@ -3058,15 +3171,15 @@ class ButtonComponentElement extends ComponentElement {
3058
3171
  if (imageEntity) {
3059
3172
  data.imageEntity = imageEntity;
3060
3173
  }
3061
- const hoverSpriteAsset = AssetElement.get(this._hoverSpriteAsset);
3174
+ const hoverSpriteAsset = useAsset(this._hoverSpriteAsset);
3062
3175
  if (hoverSpriteAsset) {
3063
3176
  data.hoverSpriteAsset = hoverSpriteAsset.id;
3064
3177
  }
3065
- const pressedSpriteAsset = AssetElement.get(this._pressedSpriteAsset);
3178
+ const pressedSpriteAsset = useAsset(this._pressedSpriteAsset);
3066
3179
  if (pressedSpriteAsset) {
3067
3180
  data.pressedSpriteAsset = pressedSpriteAsset.id;
3068
3181
  }
3069
- const inactiveSpriteAsset = AssetElement.get(this._inactiveSpriteAsset);
3182
+ const inactiveSpriteAsset = useAsset(this._inactiveSpriteAsset);
3070
3183
  if (inactiveSpriteAsset) {
3071
3184
  data.inactiveSpriteAsset = inactiveSpriteAsset.id;
3072
3185
  }
@@ -3228,7 +3341,7 @@ class ButtonComponentElement extends ComponentElement {
3228
3341
  */
3229
3342
  set hoverSpriteAsset(value) {
3230
3343
  this._hoverSpriteAsset = value;
3231
- const asset = AssetElement.get(value);
3344
+ const asset = useAsset(value);
3232
3345
  if (this.component && asset) {
3233
3346
  this.component.hoverSpriteAsset = asset.id;
3234
3347
  }
@@ -3264,7 +3377,7 @@ class ButtonComponentElement extends ComponentElement {
3264
3377
  */
3265
3378
  set pressedSpriteAsset(value) {
3266
3379
  this._pressedSpriteAsset = value;
3267
- const asset = AssetElement.get(value);
3380
+ const asset = useAsset(value);
3268
3381
  if (this.component && asset) {
3269
3382
  this.component.pressedSpriteAsset = asset.id;
3270
3383
  }
@@ -3300,7 +3413,7 @@ class ButtonComponentElement extends ComponentElement {
3300
3413
  */
3301
3414
  set inactiveSpriteAsset(value) {
3302
3415
  this._inactiveSpriteAsset = value;
3303
- const asset = AssetElement.get(value);
3416
+ const asset = useAsset(value);
3304
3417
  if (this.component && asset) {
3305
3418
  this.component.inactiveSpriteAsset = asset.id;
3306
3419
  }
@@ -4158,15 +4271,15 @@ class ElementComponentElement extends ComponentElement {
4158
4271
  };
4159
4272
  // Asset references are resolved from `<pc-asset>` element ids to engine asset ids. They are
4160
4273
  // only included when they resolve, so image/group elements (with no font) don't error.
4161
- const fontAsset = AssetElement.get(this._fontAsset);
4274
+ const fontAsset = useAsset(this._fontAsset);
4162
4275
  if (fontAsset) {
4163
4276
  data.fontAsset = fontAsset.id;
4164
4277
  }
4165
- const spriteAsset = AssetElement.get(this._spriteAsset);
4278
+ const spriteAsset = useAsset(this._spriteAsset);
4166
4279
  if (spriteAsset) {
4167
4280
  data.spriteAsset = spriteAsset.id;
4168
4281
  }
4169
- const textureAsset = AssetElement.get(this._textureAsset);
4282
+ const textureAsset = useAsset(this._textureAsset);
4170
4283
  if (textureAsset) {
4171
4284
  data.textureAsset = textureAsset.id;
4172
4285
  }
@@ -4280,7 +4393,7 @@ class ElementComponentElement extends ComponentElement {
4280
4393
  */
4281
4394
  set fontAsset(value) {
4282
4395
  this._fontAsset = value;
4283
- const asset = AssetElement.get(value);
4396
+ const asset = useAsset(value);
4284
4397
  if (this.component && asset) {
4285
4398
  this.component.fontAsset = asset.id;
4286
4399
  }
@@ -4435,7 +4548,7 @@ class ElementComponentElement extends ComponentElement {
4435
4548
  */
4436
4549
  set spriteAsset(value) {
4437
4550
  this._spriteAsset = value;
4438
- const asset = AssetElement.get(value);
4551
+ const asset = useAsset(value);
4439
4552
  if (this.component && asset) {
4440
4553
  this.component.spriteAsset = asset.id;
4441
4554
  }
@@ -4487,7 +4600,7 @@ class ElementComponentElement extends ComponentElement {
4487
4600
  */
4488
4601
  set textureAsset(value) {
4489
4602
  this._textureAsset = value;
4490
- const asset = AssetElement.get(value);
4603
+ const asset = useAsset(value);
4491
4604
  if (this.component && asset) {
4492
4605
  this.component.textureAsset = asset.id;
4493
4606
  }
@@ -5722,13 +5835,14 @@ class ParticleSystemComponentElement extends ComponentElement {
5722
5835
  super('particlesystem');
5723
5836
  }
5724
5837
  getInitialComponentData() {
5725
- const asset = AssetElement.get(this._asset);
5726
- if (!asset) {
5838
+ const asset = useAsset(this._asset);
5839
+ // A lazy config has no resource yet - _loadAsset applies it once the load completes
5840
+ if (!asset || !asset.resource) {
5727
5841
  return {};
5728
5842
  }
5729
5843
  if (asset.resource.colorMapAsset) {
5730
5844
  const id = asset.resource.colorMapAsset;
5731
- const colorMapAsset = AssetElement.get(id)?.id;
5845
+ const colorMapAsset = useAsset(id)?.id;
5732
5846
  if (colorMapAsset) {
5733
5847
  asset.resource.colorMapAsset = colorMapAsset;
5734
5848
  }
@@ -5754,9 +5868,8 @@ class ParticleSystemComponentElement extends ComponentElement {
5754
5868
  }
5755
5869
  }
5756
5870
  async _loadAsset() {
5757
- const appElement = await this.closestApp?.ready();
5758
- const app = appElement?.app;
5759
- const asset = AssetElement.get(this._asset);
5871
+ await this.closestApp?.ready();
5872
+ const asset = useAsset(this._asset);
5760
5873
  if (!asset) {
5761
5874
  return;
5762
5875
  }
@@ -5767,7 +5880,6 @@ class ParticleSystemComponentElement extends ComponentElement {
5767
5880
  asset.once('load', () => {
5768
5881
  this.applyConfig(asset.resource);
5769
5882
  });
5770
- app.assets.load(asset);
5771
5883
  }
5772
5884
  }
5773
5885
  /**
@@ -5959,6 +6071,7 @@ class MaterialElement extends HTMLElement {
5959
6071
  _metalnessMapRotation = 0;
5960
6072
  _metalnessMapTiling = new Vec2(1, 1);
5961
6073
  _metalnessMapUv = 0;
6074
+ _name = 'Untitled';
5962
6075
  _normalMap = '';
5963
6076
  _normalMapOffset = new Vec2(0, 0);
5964
6077
  _normalMapRotation = 0;
@@ -6076,6 +6189,7 @@ class MaterialElement extends HTMLElement {
6076
6189
  material.metalnessMapRotation = this._metalnessMapRotation;
6077
6190
  material.metalnessMapTiling = this._metalnessMapTiling;
6078
6191
  material.metalnessMapUv = this._metalnessMapUv;
6192
+ material.name = this._name;
6079
6193
  material.normalMapOffset = this._normalMapOffset;
6080
6194
  material.normalMapRotation = this._normalMapRotation;
6081
6195
  material.normalMapTiling = this._normalMapTiling;
@@ -6183,7 +6297,7 @@ class MaterialElement extends HTMLElement {
6183
6297
  this._scheduleUpdate();
6184
6298
  return;
6185
6299
  }
6186
- const asset = AssetElement.get(id);
6300
+ const asset = useAsset(id);
6187
6301
  if (!asset)
6188
6302
  return;
6189
6303
  if (asset.loaded) {
@@ -7162,6 +7276,26 @@ class MaterialElement extends HTMLElement {
7162
7276
  get metalnessMapUv() {
7163
7277
  return this._metalnessMapUv;
7164
7278
  }
7279
+ /**
7280
+ * Sets the name of the material.
7281
+ * @param value - The material name.
7282
+ */
7283
+ set name(value) {
7284
+ this._name = value;
7285
+ if (this.material) {
7286
+ // A label rather than shader state, so no update() is scheduled
7287
+ this.material.name = value;
7288
+ }
7289
+ }
7290
+ /**
7291
+ * Gets the name of the material - the label shown wherever materials surface by name, such
7292
+ * as profilers, GPU captures and the assignments `pc-model.hierarchy()` reports. Purely a
7293
+ * label: element references resolve through `id`.
7294
+ * @returns The material name.
7295
+ */
7296
+ get name() {
7297
+ return this._name;
7298
+ }
7165
7299
  /**
7166
7300
  * Sets the id of the `pc-asset` to use as the normal map.
7167
7301
  * @param value - The asset id.
@@ -7733,6 +7867,7 @@ class MaterialElement extends HTMLElement {
7733
7867
  'metalness-map-rotation',
7734
7868
  'metalness-map-tiling',
7735
7869
  'metalness-map-uv',
7870
+ 'name',
7736
7871
  'normal-map',
7737
7872
  'normal-map-offset',
7738
7873
  'normal-map-rotation',
@@ -7933,6 +8068,9 @@ class MaterialElement extends HTMLElement {
7933
8068
  case 'metalness-map-uv':
7934
8069
  this.metalnessMapUv = parseNumber(newValue, 0, name);
7935
8070
  break;
8071
+ case 'name':
8072
+ this.name = newValue ?? 'Untitled';
8073
+ break;
7936
8074
  case 'normal-map':
7937
8075
  this.normalMap = newValue ?? '';
7938
8076
  break;
@@ -9284,7 +9422,7 @@ const camelToKebab = (name) => {
9284
9422
  * @returns The asset, or `raw`.
9285
9423
  */
9286
9424
  const assetConversion = (rest, raw) => {
9287
- const asset = AssetElement.get(rest);
9425
+ const asset = useAsset(rest);
9288
9426
  if (asset) {
9289
9427
  return asset;
9290
9428
  }
@@ -10104,7 +10242,7 @@ class SoundSlotElement extends AsyncElement {
10104
10242
  set asset(value) {
10105
10243
  this._asset = value;
10106
10244
  if (this.soundSlot) {
10107
- const id = AssetElement.get(value)?.id;
10245
+ const id = useAsset(value)?.id;
10108
10246
  if (id) {
10109
10247
  this.soundSlot.asset = id;
10110
10248
  }
@@ -10311,7 +10449,7 @@ class GSplatComponentElement extends ComponentElement {
10311
10449
  }
10312
10450
  getInitialComponentData() {
10313
10451
  return {
10314
- asset: AssetElement.get(this._asset),
10452
+ asset: useAsset(this._asset),
10315
10453
  castShadows: this._castShadows,
10316
10454
  lodBaseDistance: this._lodBaseDistance,
10317
10455
  lodMultiplier: this._lodMultiplier,
@@ -10332,7 +10470,7 @@ class GSplatComponentElement extends ComponentElement {
10332
10470
  */
10333
10471
  set asset(value) {
10334
10472
  this._asset = value;
10335
- const asset = AssetElement.get(value);
10473
+ const asset = useAsset(value);
10336
10474
  if (this.component && asset) {
10337
10475
  this.component.asset = asset;
10338
10476
  }
@@ -10478,6 +10616,41 @@ class GSplatComponentElement extends ComponentElement {
10478
10616
  }
10479
10617
  customElements.define('pc-gsplat', GSplatComponentElement);
10480
10618
 
10619
+ /**
10620
+ * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
10621
+ * name is shared by several nodes in the model, the attached component types, and the material
10622
+ * names of a render component.
10623
+ *
10624
+ * @param node - The node to format.
10625
+ * @param counts - The number of nodes bearing each name.
10626
+ * @returns The formatted line.
10627
+ */
10628
+ const formatNode = (node, counts) => {
10629
+ const index = (counts.get(node.name) ?? 0) > 1 ? ` [${node.index}]` : '';
10630
+ const components = node.components.length > 0 ? ` (${node.components.join(', ')})` : '';
10631
+ // Braces rather than brackets: `[N]` already means a match index on this line
10632
+ const materials = node.materials.length > 0 ? ` {${node.materials.map((slot) => slot.name ?? 'null').join(', ')}}` : '';
10633
+ return `${node.name}${index}${components}${materials}`;
10634
+ };
10635
+ /**
10636
+ * Formats the printable form of a hierarchy subtree.
10637
+ *
10638
+ * @param root - The subtree root.
10639
+ * @param counts - The number of nodes bearing each name.
10640
+ * @returns The tree, one line per node.
10641
+ */
10642
+ const formatHierarchy = (root, counts) => {
10643
+ const lines = [formatNode(root, counts)];
10644
+ const walk = (node, prefix) => {
10645
+ node.children.forEach((child, i) => {
10646
+ const last = i === node.children.length - 1;
10647
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}${formatNode(child, counts)}`);
10648
+ walk(child, `${prefix}${last ? ' ' : '│ '}`);
10649
+ });
10650
+ };
10651
+ walk(root, '');
10652
+ return lines.join('\n');
10653
+ };
10481
10654
  /**
10482
10655
  * The ModelElement interface provides properties and methods for manipulating
10483
10656
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
@@ -10524,6 +10697,57 @@ class ModelElement extends AsyncElement {
10524
10697
  get entity() {
10525
10698
  return this._entity;
10526
10699
  }
10700
+ /**
10701
+ * Returns a snapshot of the instantiated node tree, or `null` while there is none (the
10702
+ * container asset has not loaded, or the element has left the document). One call grounds a
10703
+ * session — a browser console, a test, an agent — in the vocabulary `pc-node` binding
10704
+ * resolves against: the instantiated names ({@link HierarchyNode.name}), paths, match
10705
+ * indices, attached component types and the material assignments of render components
10706
+ * ({@link HierarchyNode.materials}). `String(...)` of the result, or of any node in it,
10707
+ * is the printable form.
10708
+ *
10709
+ * The snapshot is plain data, computed afresh each call: it does not follow later changes
10710
+ * to the hierarchy, and mutating it changes nothing.
10711
+ *
10712
+ * @returns The root of the instantiated node tree, or `null`.
10713
+ */
10714
+ hierarchy() {
10715
+ const root = this._entity;
10716
+ if (!root) {
10717
+ return null;
10718
+ }
10719
+ // Ordinals are assigned in the traversal resolution searches — pre-order depth-first
10720
+ // from the model root, the root itself included — so each node's index is exactly what
10721
+ // a pc-node's index attribute selects. Once the walk completes, the map holds the total
10722
+ // count per name, which is what the printable form reads to annotate only shared names.
10723
+ const ordinals = new Map();
10724
+ const describe = (entity, pathBelowRoot) => {
10725
+ const index = ordinals.get(entity.name) ?? 0;
10726
+ ordinals.set(entity.name, index + 1);
10727
+ const node = {
10728
+ name: entity.name,
10729
+ // The root has no path below itself; its own name stands in, as it does for
10730
+ // the path a pc-node bound to the root reports.
10731
+ path: pathBelowRoot || entity.name,
10732
+ index,
10733
+ // A plain GraphNode grafted into the hierarchy has no component storage
10734
+ components: Object.keys(entity.c ?? {}).sort(),
10735
+ materials: (entity.render?.meshInstances ?? []).map((meshInstance, slot) => ({
10736
+ index: slot,
10737
+ name: meshInstance.material?.name ?? null
10738
+ })),
10739
+ children: entity.children.map((child) => describe(child, pathBelowRoot ? `${pathBelowRoot}/${child.name}` : child.name))
10740
+ };
10741
+ // Non-enumerable, keeping the snapshot plain data under JSON.stringify, spreads and
10742
+ // key enumeration. Deferred to call time, by which the ordinal map holds its totals.
10743
+ Object.defineProperty(node, 'toString', {
10744
+ enumerable: false,
10745
+ value: () => formatHierarchy(node, ordinals)
10746
+ });
10747
+ return node;
10748
+ };
10749
+ return describe(root, '');
10750
+ }
10527
10751
  connectedCallback() {
10528
10752
  // A model outside an application is inert and never becomes ready, so awaiting it hangs.
10529
10753
  // Warn rather than fail silently, naming the parent it requires, as every other misplaced
@@ -10610,8 +10834,7 @@ class ModelElement extends AsyncElement {
10610
10834
  if (generation !== this._loadGeneration) {
10611
10835
  return;
10612
10836
  }
10613
- const app = appElement.app;
10614
- const asset = AssetElement.get(this._asset);
10837
+ const asset = useAsset(this._asset);
10615
10838
  if (!asset) {
10616
10839
  // An empty id is a legitimate transient (the asset may be assigned later); a
10617
10840
  // non-empty one that resolves to nothing is a dead end - say so rather than staying
@@ -10647,7 +10870,6 @@ class ModelElement extends AsyncElement {
10647
10870
  }));
10648
10871
  this._onReady();
10649
10872
  });
10650
- app.assets.load(asset);
10651
10873
  }
10652
10874
  }
10653
10875
  _unloadModel() {
@@ -10684,6 +10906,74 @@ class ModelElement extends AsyncElement {
10684
10906
  }
10685
10907
  customElements.define('pc-model', ModelElement);
10686
10908
 
10909
+ /**
10910
+ * Parses one mapping into its valid rules, warning for each entry that is not one: an unknown
10911
+ * or missing selector prefix, an empty `name:` value, an `index:` value that is not a
10912
+ * non-negative integer, or a replacement id that is not a non-empty string. An invalid rule
10913
+ * behaves exactly as if absent from the mapping.
10914
+ *
10915
+ * @param overrides - The mapping to parse.
10916
+ * @param label - The element description for warnings.
10917
+ * @returns The valid rules.
10918
+ */
10919
+ const parseMaterialRules = (overrides, label) => {
10920
+ const rules = [];
10921
+ for (const [selector, id] of Object.entries(overrides)) {
10922
+ if (typeof id !== 'string' || id === '') {
10923
+ console.warn(`${label} material-overrides '${selector}' needs a pc-material id - rule ignored`);
10924
+ }
10925
+ else if (selector.startsWith('name:')) {
10926
+ // The text after the prefix is the selector value, exactly as written - a material
10927
+ // name may legitimately begin or end with whitespace
10928
+ const name = selector.slice('name:'.length);
10929
+ if (name === '') {
10930
+ console.warn(`${label} material-overrides 'name:' selector is empty - rule ignored`);
10931
+ }
10932
+ else {
10933
+ rules.push({ kind: 'name', name, id });
10934
+ }
10935
+ }
10936
+ else if (selector.startsWith('index:')) {
10937
+ // Whitespace around the number is tolerated; Number('') is 0, so blank means NaN
10938
+ const text = selector.slice('index:'.length).trim();
10939
+ const index = text === '' ? NaN : Number(text);
10940
+ if (!Number.isInteger(index) || index < 0) {
10941
+ console.warn(`${label} material-overrides '${selector}' is not a non-negative integer index - rule ignored`);
10942
+ }
10943
+ else {
10944
+ rules.push({ kind: 'index', index, id });
10945
+ }
10946
+ }
10947
+ else {
10948
+ console.warn(`${label} material-overrides '${selector}' has no 'name:' or 'index:' prefix - rule ignored`);
10949
+ }
10950
+ }
10951
+ return rules;
10952
+ };
10953
+ /**
10954
+ * Parses the material-overrides attribute text. Anything but a JSON object — malformed JSON, an
10955
+ * array, a primitive — warns and yields `null`, the absent mapping: a stale mapping must not
10956
+ * survive an attribute value the DOM no longer represents.
10957
+ *
10958
+ * @param text - The attribute text.
10959
+ * @param label - The element description for warnings.
10960
+ * @returns The mapping, or `null`.
10961
+ */
10962
+ const parseMaterialOverridesAttribute = (text, label) => {
10963
+ let parsed;
10964
+ try {
10965
+ parsed = JSON.parse(text);
10966
+ }
10967
+ catch (error) {
10968
+ console.warn(`${label} material-overrides is not valid JSON - treated as absent: ${error.message}`);
10969
+ return null;
10970
+ }
10971
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
10972
+ console.warn(`${label} material-overrides must be a JSON object - treated as absent`);
10973
+ return null;
10974
+ }
10975
+ return parsed;
10976
+ };
10687
10977
  /**
10688
10978
  * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
10689
10979
  * resolution warnings.
@@ -10739,6 +11029,13 @@ const levenshtein = (a, b) => {
10739
11029
  * "x y z" triple.
10740
11030
  * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
10741
11031
  * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
11032
+ * @attribute {string} material-overrides - Overrides material assignments on the bound node's
11033
+ * render component, as a JSON object from selector to `pc-material` id — for example
11034
+ * `{"name:CarPaint": "candy-red", "index:7": "smoked-glass"}`. A `name:X` key selects every mesh
11035
+ * instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and
11036
+ * wins over a name rule for the same instance. Assignments no rule matches keep their baseline
11037
+ * materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to
11038
+ * discover the names and indices a node offers.
10742
11039
  * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
10743
11040
  * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
10744
11041
  * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
@@ -10774,12 +11071,25 @@ class NodeElement extends EntityBaseElement {
10774
11071
  _destroyHandle = null;
10775
11072
  /** The authored values displaced by this element's overrides, captured per property. */
10776
11073
  _authored = {};
11074
+ /**
11075
+ * The model-authored render component of the bound node, recorded at bind — before child
11076
+ * decorations build — so a render component added later by a child `pc-render` can never
11077
+ * become the override target. `null` when the bound node has none.
11078
+ */
11079
+ _authoredRender = null;
11080
+ /**
11081
+ * The baseline assignments displaced by the material overrides, captured for every mesh
11082
+ * instance when the first non-empty mapping applies and released when the mapping goes
11083
+ * absent (restoring them) or the binding dissolves.
11084
+ */
11085
+ _baseline = null;
10777
11086
  // Override values. `null` means "no override": the authored value stays in force.
10778
11087
  _enabled = null;
10779
11088
  _position = null;
10780
11089
  _rotation = null;
10781
11090
  _scale = null;
10782
11091
  _tags = null;
11092
+ _materialOverrides = null;
10783
11093
  /**
10784
11094
  * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
10785
11095
  * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
@@ -10913,6 +11223,7 @@ class NodeElement extends EntityBaseElement {
10913
11223
  this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
10914
11224
  this._state = 'bound';
10915
11225
  this._path = this._pathOf(target, hostEntity);
11226
+ this._authoredRender = target.render ?? null;
10916
11227
  this._applyOverrides();
10917
11228
  this._onReady();
10918
11229
  this._buildChildren();
@@ -10943,6 +11254,7 @@ class NodeElement extends EntityBaseElement {
10943
11254
  this._entity = null;
10944
11255
  this._path = null;
10945
11256
  this._authored = {};
11257
+ this._authoredRender = null;
10946
11258
  // Component decorations come off through the same hook the host-ready cycle uses. A
10947
11259
  // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
10948
11260
  // `_entity` is cleared, so the hook sees a host without an entity.
@@ -10964,6 +11276,9 @@ class NodeElement extends EntityBaseElement {
10964
11276
  this._entity = null;
10965
11277
  this._path = null;
10966
11278
  this._authored = {};
11279
+ this._authoredRender = null;
11280
+ // The mesh instances died with the entity - the capture is dropped, not restored
11281
+ this._baseline = null;
10967
11282
  this._state = 'pending';
10968
11283
  this._resetReady();
10969
11284
  }
@@ -11005,6 +11320,9 @@ class NodeElement extends EntityBaseElement {
11005
11320
  if (this._tags !== null) {
11006
11321
  this.tags = this._tags;
11007
11322
  }
11323
+ if (this._materialOverrides !== null) {
11324
+ this._applyMaterialOverrides();
11325
+ }
11008
11326
  }
11009
11327
  /**
11010
11328
  * Restores every authored value this element's overrides displaced. The override values
@@ -11030,6 +11348,104 @@ class NodeElement extends EntityBaseElement {
11030
11348
  entity.tags.add(authored.tags);
11031
11349
  }
11032
11350
  this._authored = {};
11351
+ this._restoreBaseline();
11352
+ }
11353
+ /**
11354
+ * Applies the material mapping to the authored render component: parse the mapping's valid
11355
+ * rules, capture the baseline on first application, then recompute every assignment from
11356
+ * that baseline - name rules write over it, index rules write over them, so `index:` wins -
11357
+ * and assign whatever changed. An absent mapping, or one with no valid rules, restores the
11358
+ * baseline instead. Called while bound, from `_applyOverrides` and the property setter.
11359
+ */
11360
+ _applyMaterialOverrides() {
11361
+ const label = `pc-node '${this._name}'`;
11362
+ const rules = this._materialOverrides ? parseMaterialRules(this._materialOverrides, label) : [];
11363
+ if (rules.length === 0) {
11364
+ this._restoreBaseline();
11365
+ return;
11366
+ }
11367
+ if (!this._baseline) {
11368
+ if (!this._authoredRender) {
11369
+ console.warn(`${label} is bound to a node without an authored render component - material-overrides ignored`);
11370
+ return;
11371
+ }
11372
+ this._baseline = this._authoredRender.meshInstances.map((meshInstance) => ({
11373
+ meshInstance,
11374
+ material: meshInstance.material ?? null,
11375
+ name: meshInstance.material?.name ?? null
11376
+ }));
11377
+ }
11378
+ const baseline = this._baseline;
11379
+ /** Resolves a replacement id, warning when it does not resolve. */
11380
+ const resolveReplacement = (id) => {
11381
+ const material = MaterialElement.get(id);
11382
+ if (!material) {
11383
+ console.warn(`${label} material-overrides could not resolve pc-material '${id}' - rule ignored`);
11384
+ }
11385
+ return material ?? null;
11386
+ };
11387
+ // Recompute the whole list from the baseline: name rules write over it, index rules
11388
+ // write over them. Recomputing makes mapping edits order-independent, and a rule whose
11389
+ // replacement does not resolve simply leaves the layer below it in force.
11390
+ const resolved = baseline.map((assignment) => assignment.material);
11391
+ for (const rule of rules) {
11392
+ if (rule.kind !== 'name') {
11393
+ continue;
11394
+ }
11395
+ const material = resolveReplacement(rule.id);
11396
+ if (!material) {
11397
+ continue;
11398
+ }
11399
+ let matched = false;
11400
+ baseline.forEach((assignment, index) => {
11401
+ if (assignment.name === rule.name) {
11402
+ resolved[index] = material;
11403
+ matched = true;
11404
+ }
11405
+ });
11406
+ if (!matched) {
11407
+ const names = baseline.map((assignment) => `'${assignment.name}'`).join(', ');
11408
+ console.warn(`${label} material-overrides 'name:${rule.name}' matches no assignment - ` +
11409
+ `baseline names: ${names || '(none)'}`);
11410
+ }
11411
+ }
11412
+ for (const rule of rules) {
11413
+ if (rule.kind !== 'index') {
11414
+ continue;
11415
+ }
11416
+ if (rule.index >= baseline.length) {
11417
+ console.warn(`${label} material-overrides 'index:${rule.index}' is out of range - ` +
11418
+ `${baseline.length} assignment(s)`);
11419
+ continue;
11420
+ }
11421
+ const material = resolveReplacement(rule.id);
11422
+ if (material) {
11423
+ resolved[rule.index] = material;
11424
+ }
11425
+ }
11426
+ baseline.forEach((assignment, index) => {
11427
+ // The engine setter rebuilds material and shader state even for a redundant write,
11428
+ // so only actual changes are assigned
11429
+ if (assignment.meshInstance.material !== resolved[index]) {
11430
+ assignment.meshInstance.material = resolved[index];
11431
+ }
11432
+ });
11433
+ }
11434
+ /**
11435
+ * Restores every baseline assignment the material overrides displaced and releases the
11436
+ * capture, so the next non-empty mapping captures afresh. Safe to call without a capture.
11437
+ */
11438
+ _restoreBaseline() {
11439
+ const baseline = this._baseline;
11440
+ if (!baseline) {
11441
+ return;
11442
+ }
11443
+ this._baseline = null;
11444
+ for (const assignment of baseline) {
11445
+ if (assignment.meshInstance.material !== assignment.material) {
11446
+ assignment.meshInstance.material = assignment.material;
11447
+ }
11448
+ }
11033
11449
  }
11034
11450
  /**
11035
11451
  * Renders the path of `node` below `root`, for the `path` property and the resolution
@@ -11250,8 +11666,41 @@ class NodeElement extends EntityBaseElement {
11250
11666
  get tags() {
11251
11667
  return this._tags;
11252
11668
  }
11669
+ /**
11670
+ * Sets the material overrides: a sparse mapping from selector to `pc-material` id, applied
11671
+ * to the bound node's authored render component. A `name:X` key selects every mesh instance
11672
+ * whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins
11673
+ * over a name rule for the same instance. Assignments no rule matches keep their baseline
11674
+ * materials. `null` clears the mapping, restoring every baseline assignment.
11675
+ * @param value - The mapping, or `null`.
11676
+ */
11677
+ set materialOverrides(value) {
11678
+ // Copied and frozen: later caller mutation of the passed object must not silently
11679
+ // disagree with the mapping the element applied
11680
+ this._materialOverrides = value === null ? null : Object.freeze({ ...value });
11681
+ if (this._state === 'bound') {
11682
+ this._applyMaterialOverrides();
11683
+ }
11684
+ }
11685
+ /**
11686
+ * Gets the material overrides.
11687
+ * @returns The mapping, or `null` while no override is set.
11688
+ */
11689
+ get materialOverrides() {
11690
+ return this._materialOverrides;
11691
+ }
11253
11692
  static get observedAttributes() {
11254
- return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
11693
+ return [
11694
+ 'enabled',
11695
+ 'index',
11696
+ 'material-overrides',
11697
+ 'name',
11698
+ 'position',
11699
+ 'rotation',
11700
+ 'scale',
11701
+ 'tags',
11702
+ ...POINTER_ATTRIBUTES
11703
+ ];
11255
11704
  }
11256
11705
  attributeChangedCallback(name, _oldValue, newValue) {
11257
11706
  switch (name) {
@@ -11276,6 +11725,10 @@ class NodeElement extends EntityBaseElement {
11276
11725
  }
11277
11726
  }
11278
11727
  break;
11728
+ case 'material-overrides':
11729
+ this.materialOverrides =
11730
+ newValue === null ? null : parseMaterialOverridesAttribute(newValue, `pc-node '${this._name}'`);
11731
+ break;
11279
11732
  case 'name':
11280
11733
  this.name = newValue ?? '';
11281
11734
  break;
@@ -11612,7 +12065,7 @@ class SkyElement extends AsyncElement {
11612
12065
  return;
11613
12066
  }
11614
12067
  this._appElement = appElement;
11615
- const asset = AssetElement.get(this._asset);
12068
+ const asset = useAsset(this._asset);
11616
12069
  if (!asset) {
11617
12070
  return;
11618
12071
  }
@@ -11631,7 +12084,6 @@ class SkyElement extends AsyncElement {
11631
12084
  }
11632
12085
  this._generateSkybox(asset);
11633
12086
  });
11634
- app.assets.load(asset);
11635
12087
  }
11636
12088
  }
11637
12089
  _unloadSkybox() {