@playcanvas/web-components 0.13.1 → 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.
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) {
@@ -1594,7 +1610,7 @@ customElements.define('pc-app', AppElement);
1594
1610
  /**
1595
1611
  * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1596
1612
  * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1597
- * @ignore
1613
+ * @internal
1598
1614
  */
1599
1615
  const POINTER_ATTRIBUTES = [
1600
1616
  'onpointerenter',
@@ -6055,6 +6071,7 @@ class MaterialElement extends HTMLElement {
6055
6071
  _metalnessMapRotation = 0;
6056
6072
  _metalnessMapTiling = new Vec2(1, 1);
6057
6073
  _metalnessMapUv = 0;
6074
+ _name = 'Untitled';
6058
6075
  _normalMap = '';
6059
6076
  _normalMapOffset = new Vec2(0, 0);
6060
6077
  _normalMapRotation = 0;
@@ -6172,6 +6189,7 @@ class MaterialElement extends HTMLElement {
6172
6189
  material.metalnessMapRotation = this._metalnessMapRotation;
6173
6190
  material.metalnessMapTiling = this._metalnessMapTiling;
6174
6191
  material.metalnessMapUv = this._metalnessMapUv;
6192
+ material.name = this._name;
6175
6193
  material.normalMapOffset = this._normalMapOffset;
6176
6194
  material.normalMapRotation = this._normalMapRotation;
6177
6195
  material.normalMapTiling = this._normalMapTiling;
@@ -7258,6 +7276,26 @@ class MaterialElement extends HTMLElement {
7258
7276
  get metalnessMapUv() {
7259
7277
  return this._metalnessMapUv;
7260
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
+ }
7261
7299
  /**
7262
7300
  * Sets the id of the `pc-asset` to use as the normal map.
7263
7301
  * @param value - The asset id.
@@ -7829,6 +7867,7 @@ class MaterialElement extends HTMLElement {
7829
7867
  'metalness-map-rotation',
7830
7868
  'metalness-map-tiling',
7831
7869
  'metalness-map-uv',
7870
+ 'name',
7832
7871
  'normal-map',
7833
7872
  'normal-map-offset',
7834
7873
  'normal-map-rotation',
@@ -8029,6 +8068,9 @@ class MaterialElement extends HTMLElement {
8029
8068
  case 'metalness-map-uv':
8030
8069
  this.metalnessMapUv = parseNumber(newValue, 0, name);
8031
8070
  break;
8071
+ case 'name':
8072
+ this.name = newValue ?? 'Untitled';
8073
+ break;
8032
8074
  case 'normal-map':
8033
8075
  this.normalMap = newValue ?? '';
8034
8076
  break;
@@ -10574,6 +10616,41 @@ class GSplatComponentElement extends ComponentElement {
10574
10616
  }
10575
10617
  customElements.define('pc-gsplat', GSplatComponentElement);
10576
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
+ };
10577
10654
  /**
10578
10655
  * The ModelElement interface provides properties and methods for manipulating
10579
10656
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
@@ -10620,6 +10697,57 @@ class ModelElement extends AsyncElement {
10620
10697
  get entity() {
10621
10698
  return this._entity;
10622
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
+ }
10623
10751
  connectedCallback() {
10624
10752
  // A model outside an application is inert and never becomes ready, so awaiting it hangs.
10625
10753
  // Warn rather than fail silently, naming the parent it requires, as every other misplaced
@@ -10778,6 +10906,74 @@ class ModelElement extends AsyncElement {
10778
10906
  }
10779
10907
  customElements.define('pc-model', ModelElement);
10780
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
+ };
10781
10977
  /**
10782
10978
  * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
10783
10979
  * resolution warnings.
@@ -10833,6 +11029,13 @@ const levenshtein = (a, b) => {
10833
11029
  * "x y z" triple.
10834
11030
  * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
10835
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.
10836
11039
  * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
10837
11040
  * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
10838
11041
  * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
@@ -10868,12 +11071,25 @@ class NodeElement extends EntityBaseElement {
10868
11071
  _destroyHandle = null;
10869
11072
  /** The authored values displaced by this element's overrides, captured per property. */
10870
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;
10871
11086
  // Override values. `null` means "no override": the authored value stays in force.
10872
11087
  _enabled = null;
10873
11088
  _position = null;
10874
11089
  _rotation = null;
10875
11090
  _scale = null;
10876
11091
  _tags = null;
11092
+ _materialOverrides = null;
10877
11093
  /**
10878
11094
  * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
10879
11095
  * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
@@ -11007,6 +11223,7 @@ class NodeElement extends EntityBaseElement {
11007
11223
  this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
11008
11224
  this._state = 'bound';
11009
11225
  this._path = this._pathOf(target, hostEntity);
11226
+ this._authoredRender = target.render ?? null;
11010
11227
  this._applyOverrides();
11011
11228
  this._onReady();
11012
11229
  this._buildChildren();
@@ -11037,6 +11254,7 @@ class NodeElement extends EntityBaseElement {
11037
11254
  this._entity = null;
11038
11255
  this._path = null;
11039
11256
  this._authored = {};
11257
+ this._authoredRender = null;
11040
11258
  // Component decorations come off through the same hook the host-ready cycle uses. A
11041
11259
  // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
11042
11260
  // `_entity` is cleared, so the hook sees a host without an entity.
@@ -11058,6 +11276,9 @@ class NodeElement extends EntityBaseElement {
11058
11276
  this._entity = null;
11059
11277
  this._path = null;
11060
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;
11061
11282
  this._state = 'pending';
11062
11283
  this._resetReady();
11063
11284
  }
@@ -11099,6 +11320,9 @@ class NodeElement extends EntityBaseElement {
11099
11320
  if (this._tags !== null) {
11100
11321
  this.tags = this._tags;
11101
11322
  }
11323
+ if (this._materialOverrides !== null) {
11324
+ this._applyMaterialOverrides();
11325
+ }
11102
11326
  }
11103
11327
  /**
11104
11328
  * Restores every authored value this element's overrides displaced. The override values
@@ -11124,6 +11348,104 @@ class NodeElement extends EntityBaseElement {
11124
11348
  entity.tags.add(authored.tags);
11125
11349
  }
11126
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
+ }
11127
11449
  }
11128
11450
  /**
11129
11451
  * Renders the path of `node` below `root`, for the `path` property and the resolution
@@ -11344,8 +11666,41 @@ class NodeElement extends EntityBaseElement {
11344
11666
  get tags() {
11345
11667
  return this._tags;
11346
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
+ }
11347
11692
  static get observedAttributes() {
11348
- 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
+ ];
11349
11704
  }
11350
11705
  attributeChangedCallback(name, _oldValue, newValue) {
11351
11706
  switch (name) {
@@ -11370,6 +11725,10 @@ class NodeElement extends EntityBaseElement {
11370
11725
  }
11371
11726
  }
11372
11727
  break;
11728
+ case 'material-overrides':
11729
+ this.materialOverrides =
11730
+ newValue === null ? null : parseMaterialOverridesAttribute(newValue, `pc-node '${this._name}'`);
11731
+ break;
11373
11732
  case 'name':
11374
11733
  this.name = newValue ?? '';
11375
11734
  break;