@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.cjs CHANGED
@@ -184,6 +184,7 @@ const REMOVAL_DELAY_MS = 250;
184
184
  * All styling is inline, so the library injects no stylesheet. The colors and height resolve CSS
185
185
  * custom properties — `--pc-loading-bar-color`, `--pc-loading-bar-background` and
186
186
  * `--pc-loading-bar-height` — so a page can theme the bar from `pc-app` or `:root`.
187
+ * @internal
187
188
  */
188
189
  class LoadingBar {
189
190
  _track;
@@ -282,6 +283,11 @@ class LoadingBar {
282
283
  }
283
284
  }
284
285
 
286
+ /**
287
+ * The CSS color keywords, lowercase name to hex value. Read by `parseColor` to accept color
288
+ * names as attribute values.
289
+ * @internal
290
+ */
285
291
  const CSS_COLORS = {
286
292
  aliceblue: '#f0f8ff',
287
293
  antiquewhite: '#faebd7',
@@ -457,7 +463,7 @@ const CSS_COLORS = {
457
463
  * @param value - The value to split.
458
464
  * @param count - The required number of components.
459
465
  * @returns The parsed components, or `null`.
460
- * @ignore
466
+ * @internal
461
467
  */
462
468
  const parseComponents = (value, count) => {
463
469
  const components = value.trim().split(/\s+/).map(Number);
@@ -488,6 +494,7 @@ const cloneDefault = (value) => {
488
494
  * @param value - The attribute value to parse (`null` when the attribute is absent).
489
495
  * @param defaultValue - The value to use when the attribute is absent or removed.
490
496
  * @returns The parsed boolean.
497
+ * @internal
491
498
  */
492
499
  const parseBool = (value, defaultValue) => {
493
500
  return value === null ? defaultValue : value !== 'false';
@@ -503,6 +510,7 @@ const parseBool = (value, defaultValue) => {
503
510
  * @param defaultValue - The value to use when the attribute is absent or invalid.
504
511
  * @param attribute - The attribute name, used in the warning message.
505
512
  * @returns The parsed Color object.
513
+ * @internal
506
514
  */
507
515
  const parseColor = (value, defaultValue, attribute) => {
508
516
  if (value === null) {
@@ -544,6 +552,7 @@ const parseColor = (value, defaultValue, attribute) => {
544
552
  * @param defaultValue - The value to use when the attribute is absent or invalid.
545
553
  * @param attribute - The attribute name, used in the warning message.
546
554
  * @returns The resolved enum name.
555
+ * @internal
547
556
  */
548
557
  const parseEnum = (value, valid, defaultValue, attribute) => {
549
558
  if (value === null) {
@@ -565,6 +574,7 @@ const parseEnum = (value, valid, defaultValue, attribute) => {
565
574
  * @param defaultValue - The value to use when the attribute is absent or invalid.
566
575
  * @param attribute - The attribute name, used in the warning message.
567
576
  * @returns The parsed number.
577
+ * @internal
568
578
  */
569
579
  const parseNumber = (value, defaultValue, attribute) => {
570
580
  if (value === null) {
@@ -587,6 +597,7 @@ const parseNumber = (value, defaultValue, attribute) => {
587
597
  * @param defaultValue - The value to use when the attribute is absent or invalid.
588
598
  * @param attribute - The attribute name, used in the warning message.
589
599
  * @returns The parsed Quat object.
600
+ * @internal
590
601
  */
591
602
  const parseQuat = (value, defaultValue, attribute) => {
592
603
  if (value === null) {
@@ -610,6 +621,7 @@ const parseQuat = (value, defaultValue, attribute) => {
610
621
  * @param value - The attribute value to parse (`null` when the attribute is absent).
611
622
  * @param defaultValue - The value to use when the attribute is absent or removed.
612
623
  * @returns The parsed tag names.
624
+ * @internal
613
625
  */
614
626
  const parseTags = (value, defaultValue = []) => {
615
627
  if (value === null) {
@@ -631,6 +643,7 @@ const parseTags = (value, defaultValue = []) => {
631
643
  * @param defaultValue - The value to use when the attribute is absent or invalid.
632
644
  * @param attribute - The attribute name, used in the warning message.
633
645
  * @returns The parsed Vec2 object.
646
+ * @internal
634
647
  */
635
648
  const parseVec2 = (value, defaultValue, attribute) => {
636
649
  if (value === null) {
@@ -652,6 +665,7 @@ const parseVec2 = (value, defaultValue, attribute) => {
652
665
  * @param defaultValue - The value to use when the attribute is absent or invalid.
653
666
  * @param attribute - The attribute name, used in the warning message.
654
667
  * @returns The parsed Vec3 object.
668
+ * @internal
655
669
  */
656
670
  const parseVec3 = (value, defaultValue, attribute) => {
657
671
  if (value === null) {
@@ -673,6 +687,7 @@ const parseVec3 = (value, defaultValue, attribute) => {
673
687
  * @param defaultValue - The value to use when the attribute is absent or invalid.
674
688
  * @param attribute - The attribute name, used in the warning message.
675
689
  * @returns The parsed Vec4 object.
690
+ * @internal
676
691
  */
677
692
  const parseVec4 = (value, defaultValue, attribute) => {
678
693
  if (value === null) {
@@ -692,6 +707,7 @@ const parseVec4 = (value, defaultValue, attribute) => {
692
707
  *
693
708
  * @param ref - The reference string to resolve.
694
709
  * @returns The resolved entity, or `null`.
710
+ * @internal
695
711
  */
696
712
  const getEntity = (ref) => {
697
713
  if (!ref) {
@@ -1596,7 +1612,7 @@ customElements.define('pc-app', AppElement);
1596
1612
  /**
1597
1613
  * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1598
1614
  * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1599
- * @ignore
1615
+ * @internal
1600
1616
  */
1601
1617
  const POINTER_ATTRIBUTES = [
1602
1618
  'onpointerenter',
@@ -6057,6 +6073,7 @@ class MaterialElement extends HTMLElement {
6057
6073
  _metalnessMapRotation = 0;
6058
6074
  _metalnessMapTiling = new playcanvas.Vec2(1, 1);
6059
6075
  _metalnessMapUv = 0;
6076
+ _name = 'Untitled';
6060
6077
  _normalMap = '';
6061
6078
  _normalMapOffset = new playcanvas.Vec2(0, 0);
6062
6079
  _normalMapRotation = 0;
@@ -6174,6 +6191,7 @@ class MaterialElement extends HTMLElement {
6174
6191
  material.metalnessMapRotation = this._metalnessMapRotation;
6175
6192
  material.metalnessMapTiling = this._metalnessMapTiling;
6176
6193
  material.metalnessMapUv = this._metalnessMapUv;
6194
+ material.name = this._name;
6177
6195
  material.normalMapOffset = this._normalMapOffset;
6178
6196
  material.normalMapRotation = this._normalMapRotation;
6179
6197
  material.normalMapTiling = this._normalMapTiling;
@@ -7260,6 +7278,26 @@ class MaterialElement extends HTMLElement {
7260
7278
  get metalnessMapUv() {
7261
7279
  return this._metalnessMapUv;
7262
7280
  }
7281
+ /**
7282
+ * Sets the name of the material.
7283
+ * @param value - The material name.
7284
+ */
7285
+ set name(value) {
7286
+ this._name = value;
7287
+ if (this.material) {
7288
+ // A label rather than shader state, so no update() is scheduled
7289
+ this.material.name = value;
7290
+ }
7291
+ }
7292
+ /**
7293
+ * Gets the name of the material - the label shown wherever materials surface by name, such
7294
+ * as profilers, GPU captures and the assignments `pc-model.hierarchy()` reports. Purely a
7295
+ * label: element references resolve through `id`.
7296
+ * @returns The material name.
7297
+ */
7298
+ get name() {
7299
+ return this._name;
7300
+ }
7263
7301
  /**
7264
7302
  * Sets the id of the `pc-asset` to use as the normal map.
7265
7303
  * @param value - The asset id.
@@ -7831,6 +7869,7 @@ class MaterialElement extends HTMLElement {
7831
7869
  'metalness-map-rotation',
7832
7870
  'metalness-map-tiling',
7833
7871
  'metalness-map-uv',
7872
+ 'name',
7834
7873
  'normal-map',
7835
7874
  'normal-map-offset',
7836
7875
  'normal-map-rotation',
@@ -8031,6 +8070,9 @@ class MaterialElement extends HTMLElement {
8031
8070
  case 'metalness-map-uv':
8032
8071
  this.metalnessMapUv = parseNumber(newValue, 0, name);
8033
8072
  break;
8073
+ case 'name':
8074
+ this.name = newValue ?? 'Untitled';
8075
+ break;
8034
8076
  case 'normal-map':
8035
8077
  this.normalMap = newValue ?? '';
8036
8078
  break;
@@ -10576,6 +10618,41 @@ class GSplatComponentElement extends ComponentElement {
10576
10618
  }
10577
10619
  customElements.define('pc-gsplat', GSplatComponentElement);
10578
10620
 
10621
+ /**
10622
+ * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
10623
+ * name is shared by several nodes in the model, the attached component types, and the material
10624
+ * names of a render component.
10625
+ *
10626
+ * @param node - The node to format.
10627
+ * @param counts - The number of nodes bearing each name.
10628
+ * @returns The formatted line.
10629
+ */
10630
+ const formatNode = (node, counts) => {
10631
+ const index = (counts.get(node.name) ?? 0) > 1 ? ` [${node.index}]` : '';
10632
+ const components = node.components.length > 0 ? ` (${node.components.join(', ')})` : '';
10633
+ // Braces rather than brackets: `[N]` already means a match index on this line
10634
+ const materials = node.materials.length > 0 ? ` {${node.materials.map((slot) => slot.name ?? 'null').join(', ')}}` : '';
10635
+ return `${node.name}${index}${components}${materials}`;
10636
+ };
10637
+ /**
10638
+ * Formats the printable form of a hierarchy subtree.
10639
+ *
10640
+ * @param root - The subtree root.
10641
+ * @param counts - The number of nodes bearing each name.
10642
+ * @returns The tree, one line per node.
10643
+ */
10644
+ const formatHierarchy = (root, counts) => {
10645
+ const lines = [formatNode(root, counts)];
10646
+ const walk = (node, prefix) => {
10647
+ node.children.forEach((child, i) => {
10648
+ const last = i === node.children.length - 1;
10649
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}${formatNode(child, counts)}`);
10650
+ walk(child, `${prefix}${last ? ' ' : '│ '}`);
10651
+ });
10652
+ };
10653
+ walk(root, '');
10654
+ return lines.join('\n');
10655
+ };
10579
10656
  /**
10580
10657
  * The ModelElement interface provides properties and methods for manipulating
10581
10658
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
@@ -10622,6 +10699,57 @@ class ModelElement extends AsyncElement {
10622
10699
  get entity() {
10623
10700
  return this._entity;
10624
10701
  }
10702
+ /**
10703
+ * Returns a snapshot of the instantiated node tree, or `null` while there is none (the
10704
+ * container asset has not loaded, or the element has left the document). One call grounds a
10705
+ * session — a browser console, a test, an agent — in the vocabulary `pc-node` binding
10706
+ * resolves against: the instantiated names ({@link HierarchyNode.name}), paths, match
10707
+ * indices, attached component types and the material assignments of render components
10708
+ * ({@link HierarchyNode.materials}). `String(...)` of the result, or of any node in it,
10709
+ * is the printable form.
10710
+ *
10711
+ * The snapshot is plain data, computed afresh each call: it does not follow later changes
10712
+ * to the hierarchy, and mutating it changes nothing.
10713
+ *
10714
+ * @returns The root of the instantiated node tree, or `null`.
10715
+ */
10716
+ hierarchy() {
10717
+ const root = this._entity;
10718
+ if (!root) {
10719
+ return null;
10720
+ }
10721
+ // Ordinals are assigned in the traversal resolution searches — pre-order depth-first
10722
+ // from the model root, the root itself included — so each node's index is exactly what
10723
+ // a pc-node's index attribute selects. Once the walk completes, the map holds the total
10724
+ // count per name, which is what the printable form reads to annotate only shared names.
10725
+ const ordinals = new Map();
10726
+ const describe = (entity, pathBelowRoot) => {
10727
+ const index = ordinals.get(entity.name) ?? 0;
10728
+ ordinals.set(entity.name, index + 1);
10729
+ const node = {
10730
+ name: entity.name,
10731
+ // The root has no path below itself; its own name stands in, as it does for
10732
+ // the path a pc-node bound to the root reports.
10733
+ path: pathBelowRoot || entity.name,
10734
+ index,
10735
+ // A plain GraphNode grafted into the hierarchy has no component storage
10736
+ components: Object.keys(entity.c ?? {}).sort(),
10737
+ materials: (entity.render?.meshInstances ?? []).map((meshInstance, slot) => ({
10738
+ index: slot,
10739
+ name: meshInstance.material?.name ?? null
10740
+ })),
10741
+ children: entity.children.map((child) => describe(child, pathBelowRoot ? `${pathBelowRoot}/${child.name}` : child.name))
10742
+ };
10743
+ // Non-enumerable, keeping the snapshot plain data under JSON.stringify, spreads and
10744
+ // key enumeration. Deferred to call time, by which the ordinal map holds its totals.
10745
+ Object.defineProperty(node, 'toString', {
10746
+ enumerable: false,
10747
+ value: () => formatHierarchy(node, ordinals)
10748
+ });
10749
+ return node;
10750
+ };
10751
+ return describe(root, '');
10752
+ }
10625
10753
  connectedCallback() {
10626
10754
  // A model outside an application is inert and never becomes ready, so awaiting it hangs.
10627
10755
  // Warn rather than fail silently, naming the parent it requires, as every other misplaced
@@ -10780,6 +10908,74 @@ class ModelElement extends AsyncElement {
10780
10908
  }
10781
10909
  customElements.define('pc-model', ModelElement);
10782
10910
 
10911
+ /**
10912
+ * Parses one mapping into its valid rules, warning for each entry that is not one: an unknown
10913
+ * or missing selector prefix, an empty `name:` value, an `index:` value that is not a
10914
+ * non-negative integer, or a replacement id that is not a non-empty string. An invalid rule
10915
+ * behaves exactly as if absent from the mapping.
10916
+ *
10917
+ * @param overrides - The mapping to parse.
10918
+ * @param label - The element description for warnings.
10919
+ * @returns The valid rules.
10920
+ */
10921
+ const parseMaterialRules = (overrides, label) => {
10922
+ const rules = [];
10923
+ for (const [selector, id] of Object.entries(overrides)) {
10924
+ if (typeof id !== 'string' || id === '') {
10925
+ console.warn(`${label} material-overrides '${selector}' needs a pc-material id - rule ignored`);
10926
+ }
10927
+ else if (selector.startsWith('name:')) {
10928
+ // The text after the prefix is the selector value, exactly as written - a material
10929
+ // name may legitimately begin or end with whitespace
10930
+ const name = selector.slice('name:'.length);
10931
+ if (name === '') {
10932
+ console.warn(`${label} material-overrides 'name:' selector is empty - rule ignored`);
10933
+ }
10934
+ else {
10935
+ rules.push({ kind: 'name', name, id });
10936
+ }
10937
+ }
10938
+ else if (selector.startsWith('index:')) {
10939
+ // Whitespace around the number is tolerated; Number('') is 0, so blank means NaN
10940
+ const text = selector.slice('index:'.length).trim();
10941
+ const index = text === '' ? NaN : Number(text);
10942
+ if (!Number.isInteger(index) || index < 0) {
10943
+ console.warn(`${label} material-overrides '${selector}' is not a non-negative integer index - rule ignored`);
10944
+ }
10945
+ else {
10946
+ rules.push({ kind: 'index', index, id });
10947
+ }
10948
+ }
10949
+ else {
10950
+ console.warn(`${label} material-overrides '${selector}' has no 'name:' or 'index:' prefix - rule ignored`);
10951
+ }
10952
+ }
10953
+ return rules;
10954
+ };
10955
+ /**
10956
+ * Parses the material-overrides attribute text. Anything but a JSON object — malformed JSON, an
10957
+ * array, a primitive — warns and yields `null`, the absent mapping: a stale mapping must not
10958
+ * survive an attribute value the DOM no longer represents.
10959
+ *
10960
+ * @param text - The attribute text.
10961
+ * @param label - The element description for warnings.
10962
+ * @returns The mapping, or `null`.
10963
+ */
10964
+ const parseMaterialOverridesAttribute = (text, label) => {
10965
+ let parsed;
10966
+ try {
10967
+ parsed = JSON.parse(text);
10968
+ }
10969
+ catch (error) {
10970
+ console.warn(`${label} material-overrides is not valid JSON - treated as absent: ${error.message}`);
10971
+ return null;
10972
+ }
10973
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
10974
+ console.warn(`${label} material-overrides must be a JSON object - treated as absent`);
10975
+ return null;
10976
+ }
10977
+ return parsed;
10978
+ };
10783
10979
  /**
10784
10980
  * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
10785
10981
  * resolution warnings.
@@ -10835,6 +11031,13 @@ const levenshtein = (a, b) => {
10835
11031
  * "x y z" triple.
10836
11032
  * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
10837
11033
  * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
11034
+ * @attribute {string} material-overrides - Overrides material assignments on the bound node's
11035
+ * render component, as a JSON object from selector to `pc-material` id — for example
11036
+ * `{"name:CarPaint": "candy-red", "index:7": "smoked-glass"}`. A `name:X` key selects every mesh
11037
+ * instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and
11038
+ * wins over a name rule for the same instance. Assignments no rule matches keep their baseline
11039
+ * materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to
11040
+ * discover the names and indices a node offers.
10838
11041
  * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
10839
11042
  * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
10840
11043
  * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
@@ -10870,12 +11073,25 @@ class NodeElement extends EntityBaseElement {
10870
11073
  _destroyHandle = null;
10871
11074
  /** The authored values displaced by this element's overrides, captured per property. */
10872
11075
  _authored = {};
11076
+ /**
11077
+ * The model-authored render component of the bound node, recorded at bind — before child
11078
+ * decorations build — so a render component added later by a child `pc-render` can never
11079
+ * become the override target. `null` when the bound node has none.
11080
+ */
11081
+ _authoredRender = null;
11082
+ /**
11083
+ * The baseline assignments displaced by the material overrides, captured for every mesh
11084
+ * instance when the first non-empty mapping applies and released when the mapping goes
11085
+ * absent (restoring them) or the binding dissolves.
11086
+ */
11087
+ _baseline = null;
10873
11088
  // Override values. `null` means "no override": the authored value stays in force.
10874
11089
  _enabled = null;
10875
11090
  _position = null;
10876
11091
  _rotation = null;
10877
11092
  _scale = null;
10878
11093
  _tags = null;
11094
+ _materialOverrides = null;
10879
11095
  /**
10880
11096
  * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
10881
11097
  * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
@@ -11009,6 +11225,7 @@ class NodeElement extends EntityBaseElement {
11009
11225
  this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
11010
11226
  this._state = 'bound';
11011
11227
  this._path = this._pathOf(target, hostEntity);
11228
+ this._authoredRender = target.render ?? null;
11012
11229
  this._applyOverrides();
11013
11230
  this._onReady();
11014
11231
  this._buildChildren();
@@ -11039,6 +11256,7 @@ class NodeElement extends EntityBaseElement {
11039
11256
  this._entity = null;
11040
11257
  this._path = null;
11041
11258
  this._authored = {};
11259
+ this._authoredRender = null;
11042
11260
  // Component decorations come off through the same hook the host-ready cycle uses. A
11043
11261
  // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
11044
11262
  // `_entity` is cleared, so the hook sees a host without an entity.
@@ -11060,6 +11278,9 @@ class NodeElement extends EntityBaseElement {
11060
11278
  this._entity = null;
11061
11279
  this._path = null;
11062
11280
  this._authored = {};
11281
+ this._authoredRender = null;
11282
+ // The mesh instances died with the entity - the capture is dropped, not restored
11283
+ this._baseline = null;
11063
11284
  this._state = 'pending';
11064
11285
  this._resetReady();
11065
11286
  }
@@ -11101,6 +11322,9 @@ class NodeElement extends EntityBaseElement {
11101
11322
  if (this._tags !== null) {
11102
11323
  this.tags = this._tags;
11103
11324
  }
11325
+ if (this._materialOverrides !== null) {
11326
+ this._applyMaterialOverrides();
11327
+ }
11104
11328
  }
11105
11329
  /**
11106
11330
  * Restores every authored value this element's overrides displaced. The override values
@@ -11126,6 +11350,104 @@ class NodeElement extends EntityBaseElement {
11126
11350
  entity.tags.add(authored.tags);
11127
11351
  }
11128
11352
  this._authored = {};
11353
+ this._restoreBaseline();
11354
+ }
11355
+ /**
11356
+ * Applies the material mapping to the authored render component: parse the mapping's valid
11357
+ * rules, capture the baseline on first application, then recompute every assignment from
11358
+ * that baseline - name rules write over it, index rules write over them, so `index:` wins -
11359
+ * and assign whatever changed. An absent mapping, or one with no valid rules, restores the
11360
+ * baseline instead. Called while bound, from `_applyOverrides` and the property setter.
11361
+ */
11362
+ _applyMaterialOverrides() {
11363
+ const label = `pc-node '${this._name}'`;
11364
+ const rules = this._materialOverrides ? parseMaterialRules(this._materialOverrides, label) : [];
11365
+ if (rules.length === 0) {
11366
+ this._restoreBaseline();
11367
+ return;
11368
+ }
11369
+ if (!this._baseline) {
11370
+ if (!this._authoredRender) {
11371
+ console.warn(`${label} is bound to a node without an authored render component - material-overrides ignored`);
11372
+ return;
11373
+ }
11374
+ this._baseline = this._authoredRender.meshInstances.map((meshInstance) => ({
11375
+ meshInstance,
11376
+ material: meshInstance.material ?? null,
11377
+ name: meshInstance.material?.name ?? null
11378
+ }));
11379
+ }
11380
+ const baseline = this._baseline;
11381
+ /** Resolves a replacement id, warning when it does not resolve. */
11382
+ const resolveReplacement = (id) => {
11383
+ const material = MaterialElement.get(id);
11384
+ if (!material) {
11385
+ console.warn(`${label} material-overrides could not resolve pc-material '${id}' - rule ignored`);
11386
+ }
11387
+ return material ?? null;
11388
+ };
11389
+ // Recompute the whole list from the baseline: name rules write over it, index rules
11390
+ // write over them. Recomputing makes mapping edits order-independent, and a rule whose
11391
+ // replacement does not resolve simply leaves the layer below it in force.
11392
+ const resolved = baseline.map((assignment) => assignment.material);
11393
+ for (const rule of rules) {
11394
+ if (rule.kind !== 'name') {
11395
+ continue;
11396
+ }
11397
+ const material = resolveReplacement(rule.id);
11398
+ if (!material) {
11399
+ continue;
11400
+ }
11401
+ let matched = false;
11402
+ baseline.forEach((assignment, index) => {
11403
+ if (assignment.name === rule.name) {
11404
+ resolved[index] = material;
11405
+ matched = true;
11406
+ }
11407
+ });
11408
+ if (!matched) {
11409
+ const names = baseline.map((assignment) => `'${assignment.name}'`).join(', ');
11410
+ console.warn(`${label} material-overrides 'name:${rule.name}' matches no assignment - ` +
11411
+ `baseline names: ${names || '(none)'}`);
11412
+ }
11413
+ }
11414
+ for (const rule of rules) {
11415
+ if (rule.kind !== 'index') {
11416
+ continue;
11417
+ }
11418
+ if (rule.index >= baseline.length) {
11419
+ console.warn(`${label} material-overrides 'index:${rule.index}' is out of range - ` +
11420
+ `${baseline.length} assignment(s)`);
11421
+ continue;
11422
+ }
11423
+ const material = resolveReplacement(rule.id);
11424
+ if (material) {
11425
+ resolved[rule.index] = material;
11426
+ }
11427
+ }
11428
+ baseline.forEach((assignment, index) => {
11429
+ // The engine setter rebuilds material and shader state even for a redundant write,
11430
+ // so only actual changes are assigned
11431
+ if (assignment.meshInstance.material !== resolved[index]) {
11432
+ assignment.meshInstance.material = resolved[index];
11433
+ }
11434
+ });
11435
+ }
11436
+ /**
11437
+ * Restores every baseline assignment the material overrides displaced and releases the
11438
+ * capture, so the next non-empty mapping captures afresh. Safe to call without a capture.
11439
+ */
11440
+ _restoreBaseline() {
11441
+ const baseline = this._baseline;
11442
+ if (!baseline) {
11443
+ return;
11444
+ }
11445
+ this._baseline = null;
11446
+ for (const assignment of baseline) {
11447
+ if (assignment.meshInstance.material !== assignment.material) {
11448
+ assignment.meshInstance.material = assignment.material;
11449
+ }
11450
+ }
11129
11451
  }
11130
11452
  /**
11131
11453
  * Renders the path of `node` below `root`, for the `path` property and the resolution
@@ -11346,8 +11668,41 @@ class NodeElement extends EntityBaseElement {
11346
11668
  get tags() {
11347
11669
  return this._tags;
11348
11670
  }
11671
+ /**
11672
+ * Sets the material overrides: a sparse mapping from selector to `pc-material` id, applied
11673
+ * to the bound node's authored render component. A `name:X` key selects every mesh instance
11674
+ * whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins
11675
+ * over a name rule for the same instance. Assignments no rule matches keep their baseline
11676
+ * materials. `null` clears the mapping, restoring every baseline assignment.
11677
+ * @param value - The mapping, or `null`.
11678
+ */
11679
+ set materialOverrides(value) {
11680
+ // Copied and frozen: later caller mutation of the passed object must not silently
11681
+ // disagree with the mapping the element applied
11682
+ this._materialOverrides = value === null ? null : Object.freeze({ ...value });
11683
+ if (this._state === 'bound') {
11684
+ this._applyMaterialOverrides();
11685
+ }
11686
+ }
11687
+ /**
11688
+ * Gets the material overrides.
11689
+ * @returns The mapping, or `null` while no override is set.
11690
+ */
11691
+ get materialOverrides() {
11692
+ return this._materialOverrides;
11693
+ }
11349
11694
  static get observedAttributes() {
11350
- return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
11695
+ return [
11696
+ 'enabled',
11697
+ 'index',
11698
+ 'material-overrides',
11699
+ 'name',
11700
+ 'position',
11701
+ 'rotation',
11702
+ 'scale',
11703
+ 'tags',
11704
+ ...POINTER_ATTRIBUTES
11705
+ ];
11351
11706
  }
11352
11707
  attributeChangedCallback(name, _oldValue, newValue) {
11353
11708
  switch (name) {
@@ -11372,6 +11727,10 @@ class NodeElement extends EntityBaseElement {
11372
11727
  }
11373
11728
  }
11374
11729
  break;
11730
+ case 'material-overrides':
11731
+ this.materialOverrides =
11732
+ newValue === null ? null : parseMaterialOverridesAttribute(newValue, `pc-node '${this._name}'`);
11733
+ break;
11375
11734
  case 'name':
11376
11735
  this.name = newValue ?? '';
11377
11736
  break;