@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
@@ -8,8 +8,9 @@ const REMOVAL_DELAY_MS = 250;
8
8
  * All styling is inline, so the library injects no stylesheet. The colors and height resolve CSS
9
9
  * custom properties — `--pc-loading-bar-color`, `--pc-loading-bar-background` and
10
10
  * `--pc-loading-bar-height` — so a page can theme the bar from `pc-app` or `:root`.
11
+ * @internal
11
12
  */
12
- class LoadingBar {
13
+ export class LoadingBar {
13
14
  private _track: HTMLDivElement;
14
15
 
15
16
  private _fill: HTMLDivElement;
@@ -118,5 +119,3 @@ class LoadingBar {
118
119
  this._track.remove();
119
120
  }
120
121
  }
121
-
122
- export { LoadingBar };
package/src/material.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  import type { EventHandle, Texture } from 'playcanvas';
27
27
 
28
28
  import type { AppElement } from './app';
29
- import { AssetElement } from './asset';
29
+ import { useAsset } from './asset';
30
30
  import { parseBool, parseColor, parseEnum, parseNumber, parseVec2 } from './parse';
31
31
 
32
32
  type BlendType =
@@ -251,6 +251,8 @@ class MaterialElement extends HTMLElement {
251
251
 
252
252
  private _metalnessMapUv = 0;
253
253
 
254
+ private _name = 'Untitled';
255
+
254
256
  private _normalMap = '';
255
257
 
256
258
  private _normalMapOffset = new Vec2(0, 0);
@@ -402,6 +404,7 @@ class MaterialElement extends HTMLElement {
402
404
  material.metalnessMapRotation = this._metalnessMapRotation;
403
405
  material.metalnessMapTiling = this._metalnessMapTiling;
404
406
  material.metalnessMapUv = this._metalnessMapUv;
407
+ material.name = this._name;
405
408
  material.normalMapOffset = this._normalMapOffset;
406
409
  material.normalMapRotation = this._normalMapRotation;
407
410
  material.normalMapTiling = this._normalMapTiling;
@@ -523,7 +526,7 @@ class MaterialElement extends HTMLElement {
523
526
  return;
524
527
  }
525
528
 
526
- const asset = AssetElement.get(id);
529
+ const asset = useAsset(id);
527
530
  if (!asset) return;
528
531
 
529
532
  if (asset.loaded) {
@@ -1615,6 +1618,28 @@ class MaterialElement extends HTMLElement {
1615
1618
  return this._metalnessMapUv;
1616
1619
  }
1617
1620
 
1621
+ /**
1622
+ * Sets the name of the material.
1623
+ * @param value - The material name.
1624
+ */
1625
+ set name(value: string) {
1626
+ this._name = value;
1627
+ if (this.material) {
1628
+ // A label rather than shader state, so no update() is scheduled
1629
+ this.material.name = value;
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * Gets the name of the material - the label shown wherever materials surface by name, such
1635
+ * as profilers, GPU captures and the assignments `pc-model.hierarchy()` reports. Purely a
1636
+ * label: element references resolve through `id`.
1637
+ * @returns The material name.
1638
+ */
1639
+ get name() {
1640
+ return this._name;
1641
+ }
1642
+
1618
1643
  /**
1619
1644
  * Sets the id of the `pc-asset` to use as the normal map.
1620
1645
  * @param value - The asset id.
@@ -2243,6 +2268,7 @@ class MaterialElement extends HTMLElement {
2243
2268
  'metalness-map-rotation',
2244
2269
  'metalness-map-tiling',
2245
2270
  'metalness-map-uv',
2271
+ 'name',
2246
2272
  'normal-map',
2247
2273
  'normal-map-offset',
2248
2274
  'normal-map-rotation',
@@ -2444,6 +2470,9 @@ class MaterialElement extends HTMLElement {
2444
2470
  case 'metalness-map-uv':
2445
2471
  this.metalnessMapUv = parseNumber(newValue, 0, name);
2446
2472
  break;
2473
+ case 'name':
2474
+ this.name = newValue ?? 'Untitled';
2475
+ break;
2447
2476
  case 'normal-map':
2448
2477
  this.normalMap = newValue ?? '';
2449
2478
  break;
package/src/model.ts CHANGED
@@ -1,8 +1,103 @@
1
1
  import type { ContainerResource, Entity, EventHandle } from 'playcanvas';
2
2
 
3
- import { AssetElement } from './asset';
3
+ import { useAsset } from './asset';
4
4
  import { AsyncElement } from './async-element';
5
5
 
6
+ /**
7
+ * One material assignment of a {@link HierarchyNode} with a render component: a mesh instance's
8
+ * position within the component and the runtime name of its current material.
9
+ */
10
+ type HierarchyMaterial = {
11
+ /** The mesh instance's position in the render component's `meshInstances` array. */
12
+ index: number;
13
+ /**
14
+ * The runtime name of the mesh instance's current material, reported as-is: the engine
15
+ * names an unnamed glTF material `Untitled`, and assigns a shared material named
16
+ * `defaultGlbMaterial` to a primitive authored without one — neither is a unique authored
17
+ * identifier. `null` when a script has cleared the assignment.
18
+ */
19
+ name: string | null;
20
+ };
21
+
22
+ /**
23
+ * One node of the tree returned by {@link ModelElement.hierarchy}. A plain-data snapshot —
24
+ * `JSON.stringify` serializes it — whose `toString()` renders the node's subtree as a printable
25
+ * tree.
26
+ */
27
+ type HierarchyNode = {
28
+ /**
29
+ * The node's name as instantiated, which is the name `pc-node` binding resolves: the engine
30
+ * parser synthesizes `node_<index>` names for unnamed nodes and renames identically named
31
+ * siblings apart (`Wheel`, `Wheel1`, ...), so it can differ from the name authored in the
32
+ * source asset.
33
+ */
34
+ name: string;
35
+ /**
36
+ * The node's `/`-separated path below the model root — the path a `pc-node` bound to this
37
+ * node reports. The root's path is its own name.
38
+ */
39
+ path: string;
40
+ /**
41
+ * The node's position among identically named nodes in the model, counted in depth-first
42
+ * order over the whole tree: the match a `pc-node`'s `index` attribute selects when `name`
43
+ * alone is ambiguous.
44
+ */
45
+ index: number;
46
+ /** The types of the components attached to the node (e.g. 'render'), sorted. */
47
+ components: string[];
48
+ /**
49
+ * The material assignments of the node's render component, one entry per mesh instance in
50
+ * component order. Empty for a node without a render component.
51
+ */
52
+ materials: HierarchyMaterial[];
53
+ /** The node's children. */
54
+ children: HierarchyNode[];
55
+ /**
56
+ * Renders the subtree rooted at this node as a printable tree, one line per node: the name,
57
+ * `[index]` after a name that several nodes in the model share, the component types in
58
+ * parentheses, and the material names of a render component in braces.
59
+ */
60
+ toString(): string;
61
+ };
62
+
63
+ /**
64
+ * Formats one line of the printable hierarchy: the node's name, an `[index]` marker when the
65
+ * name is shared by several nodes in the model, the attached component types, and the material
66
+ * names of a render component.
67
+ *
68
+ * @param node - The node to format.
69
+ * @param counts - The number of nodes bearing each name.
70
+ * @returns The formatted line.
71
+ */
72
+ const formatNode = (node: HierarchyNode, counts: ReadonlyMap<string, number>): string => {
73
+ const index = (counts.get(node.name) ?? 0) > 1 ? ` [${node.index}]` : '';
74
+ const components = node.components.length > 0 ? ` (${node.components.join(', ')})` : '';
75
+ // Braces rather than brackets: `[N]` already means a match index on this line
76
+ const materials =
77
+ node.materials.length > 0 ? ` {${node.materials.map((slot) => slot.name ?? 'null').join(', ')}}` : '';
78
+ return `${node.name}${index}${components}${materials}`;
79
+ };
80
+
81
+ /**
82
+ * Formats the printable form of a hierarchy subtree.
83
+ *
84
+ * @param root - The subtree root.
85
+ * @param counts - The number of nodes bearing each name.
86
+ * @returns The tree, one line per node.
87
+ */
88
+ const formatHierarchy = (root: HierarchyNode, counts: ReadonlyMap<string, number>): string => {
89
+ const lines = [formatNode(root, counts)];
90
+ const walk = (node: HierarchyNode, prefix: string) => {
91
+ node.children.forEach((child, i) => {
92
+ const last = i === node.children.length - 1;
93
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}${formatNode(child, counts)}`);
94
+ walk(child, `${prefix}${last ? ' ' : '│ '}`);
95
+ });
96
+ };
97
+ walk(root, '');
98
+ return lines.join('\n');
99
+ };
100
+
6
101
  /**
7
102
  * The ModelElement interface provides properties and methods for manipulating
8
103
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/ | `<pc-model>`} elements.
@@ -55,6 +150,66 @@ class ModelElement extends AsyncElement {
55
150
  return this._entity;
56
151
  }
57
152
 
153
+ /**
154
+ * Returns a snapshot of the instantiated node tree, or `null` while there is none (the
155
+ * container asset has not loaded, or the element has left the document). One call grounds a
156
+ * session — a browser console, a test, an agent — in the vocabulary `pc-node` binding
157
+ * resolves against: the instantiated names ({@link HierarchyNode.name}), paths, match
158
+ * indices, attached component types and the material assignments of render components
159
+ * ({@link HierarchyNode.materials}). `String(...)` of the result, or of any node in it,
160
+ * is the printable form.
161
+ *
162
+ * The snapshot is plain data, computed afresh each call: it does not follow later changes
163
+ * to the hierarchy, and mutating it changes nothing.
164
+ *
165
+ * @returns The root of the instantiated node tree, or `null`.
166
+ */
167
+ hierarchy(): HierarchyNode | null {
168
+ const root = this._entity;
169
+ if (!root) {
170
+ return null;
171
+ }
172
+
173
+ // Ordinals are assigned in the traversal resolution searches — pre-order depth-first
174
+ // from the model root, the root itself included — so each node's index is exactly what
175
+ // a pc-node's index attribute selects. Once the walk completes, the map holds the total
176
+ // count per name, which is what the printable form reads to annotate only shared names.
177
+ const ordinals = new Map<string, number>();
178
+
179
+ const describe = (entity: Entity, pathBelowRoot: string): HierarchyNode => {
180
+ const index = ordinals.get(entity.name) ?? 0;
181
+ ordinals.set(entity.name, index + 1);
182
+
183
+ const node: HierarchyNode = {
184
+ name: entity.name,
185
+ // The root has no path below itself; its own name stands in, as it does for
186
+ // the path a pc-node bound to the root reports.
187
+ path: pathBelowRoot || entity.name,
188
+ index,
189
+ // A plain GraphNode grafted into the hierarchy has no component storage
190
+ components: Object.keys(entity.c ?? {}).sort(),
191
+ materials: (entity.render?.meshInstances ?? []).map((meshInstance, slot) => ({
192
+ index: slot,
193
+ name: meshInstance.material?.name ?? null
194
+ })),
195
+ children: entity.children.map((child) =>
196
+ describe(child as Entity, pathBelowRoot ? `${pathBelowRoot}/${child.name}` : child.name)
197
+ )
198
+ };
199
+
200
+ // Non-enumerable, keeping the snapshot plain data under JSON.stringify, spreads and
201
+ // key enumeration. Deferred to call time, by which the ordinal map holds its totals.
202
+ Object.defineProperty(node, 'toString', {
203
+ enumerable: false,
204
+ value: () => formatHierarchy(node, ordinals)
205
+ });
206
+
207
+ return node;
208
+ };
209
+
210
+ return describe(root, '');
211
+ }
212
+
58
213
  connectedCallback() {
59
214
  // A model outside an application is inert and never becomes ready, so awaiting it hangs.
60
215
  // Warn rather than fail silently, naming the parent it requires, as every other misplaced
@@ -154,9 +309,7 @@ class ModelElement extends AsyncElement {
154
309
  return;
155
310
  }
156
311
 
157
- const app = appElement.app;
158
-
159
- const asset = AssetElement.get(this._asset);
312
+ const asset = useAsset(this._asset);
160
313
  if (!asset) {
161
314
  // An empty id is a legitimate transient (the asset may be assigned later); a
162
315
  // non-empty one that resolves to nothing is a dead end - say so rather than staying
@@ -194,7 +347,6 @@ class ModelElement extends AsyncElement {
194
347
  );
195
348
  this._onReady();
196
349
  });
197
- app!.assets.load(asset);
198
350
  }
199
351
  }
200
352
 
@@ -238,3 +390,4 @@ class ModelElement extends AsyncElement {
238
390
  customElements.define('pc-model', ModelElement);
239
391
 
240
392
  export { ModelElement };
393
+ export type { HierarchyMaterial, HierarchyNode };
package/src/node.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Entity, EventHandle, GraphNode, Quat } from 'playcanvas';
1
+ import type { Entity, EventHandle, GraphNode, Material, MeshInstance, Quat, RenderComponent } from 'playcanvas';
2
2
  import { Vec3 } from 'playcanvas';
3
3
 
4
4
  import { ComponentElement } from './components/component';
5
5
  import type { EntityElement } from './entity';
6
6
  import { EntityBaseElement, POINTER_ATTRIBUTES } from './entity-base';
7
+ import { MaterialElement } from './material';
7
8
  import { ModelElement } from './model';
8
9
  import { parseBool, parseTags, parseVec3 } from './parse';
9
10
 
@@ -27,6 +28,95 @@ type AuthoredState = {
27
28
  tags?: string[];
28
29
  };
29
30
 
31
+ /**
32
+ * A sparse mapping from selector to `pc-material` id, as carried by the `material-overrides`
33
+ * attribute and `materialOverrides` property. A `name:X` key selects every mesh instance of the
34
+ * bound node's render component whose baseline material is named `X`; an `index:N` key selects
35
+ * mesh instance `N` and wins over a name rule for the same instance.
36
+ */
37
+ type MaterialOverrides = Readonly<Record<string, string>>;
38
+
39
+ /**
40
+ * One baseline assignment, captured for every mesh instance of the authored render component
41
+ * when the first material override applies: the mesh instance, the material it displaced, and
42
+ * that material's name at capture time — the name `name:` selectors match, immune to later
43
+ * renames. `material` is `null` when a script had already cleared the assignment.
44
+ */
45
+ type BaselineAssignment = {
46
+ meshInstance: MeshInstance;
47
+ material: Material | null;
48
+ name: string | null;
49
+ };
50
+
51
+ /** One selector of a material-overrides mapping, in parsed form. */
52
+ type MaterialRule = { kind: 'name'; name: string; id: string } | { kind: 'index'; index: number; id: string };
53
+
54
+ /**
55
+ * Parses one mapping into its valid rules, warning for each entry that is not one: an unknown
56
+ * or missing selector prefix, an empty `name:` value, an `index:` value that is not a
57
+ * non-negative integer, or a replacement id that is not a non-empty string. An invalid rule
58
+ * behaves exactly as if absent from the mapping.
59
+ *
60
+ * @param overrides - The mapping to parse.
61
+ * @param label - The element description for warnings.
62
+ * @returns The valid rules.
63
+ */
64
+ const parseMaterialRules = (overrides: MaterialOverrides, label: string): MaterialRule[] => {
65
+ const rules: MaterialRule[] = [];
66
+ for (const [selector, id] of Object.entries(overrides)) {
67
+ if (typeof id !== 'string' || id === '') {
68
+ console.warn(`${label} material-overrides '${selector}' needs a pc-material id - rule ignored`);
69
+ } else if (selector.startsWith('name:')) {
70
+ // The text after the prefix is the selector value, exactly as written - a material
71
+ // name may legitimately begin or end with whitespace
72
+ const name = selector.slice('name:'.length);
73
+ if (name === '') {
74
+ console.warn(`${label} material-overrides 'name:' selector is empty - rule ignored`);
75
+ } else {
76
+ rules.push({ kind: 'name', name, id });
77
+ }
78
+ } else if (selector.startsWith('index:')) {
79
+ // Whitespace around the number is tolerated; Number('') is 0, so blank means NaN
80
+ const text = selector.slice('index:'.length).trim();
81
+ const index = text === '' ? NaN : Number(text);
82
+ if (!Number.isInteger(index) || index < 0) {
83
+ console.warn(
84
+ `${label} material-overrides '${selector}' is not a non-negative integer index - rule ignored`
85
+ );
86
+ } else {
87
+ rules.push({ kind: 'index', index, id });
88
+ }
89
+ } else {
90
+ console.warn(`${label} material-overrides '${selector}' has no 'name:' or 'index:' prefix - rule ignored`);
91
+ }
92
+ }
93
+ return rules;
94
+ };
95
+
96
+ /**
97
+ * Parses the material-overrides attribute text. Anything but a JSON object — malformed JSON, an
98
+ * array, a primitive — warns and yields `null`, the absent mapping: a stale mapping must not
99
+ * survive an attribute value the DOM no longer represents.
100
+ *
101
+ * @param text - The attribute text.
102
+ * @param label - The element description for warnings.
103
+ * @returns The mapping, or `null`.
104
+ */
105
+ const parseMaterialOverridesAttribute = (text: string, label: string): MaterialOverrides | null => {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(text);
109
+ } catch (error) {
110
+ console.warn(`${label} material-overrides is not valid JSON - treated as absent: ${(error as Error).message}`);
111
+ return null;
112
+ }
113
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
114
+ console.warn(`${label} material-overrides must be a JSON object - treated as absent`);
115
+ return null;
116
+ }
117
+ return parsed as MaterialOverrides;
118
+ };
119
+
30
120
  /**
31
121
  * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
32
122
  * resolution warnings.
@@ -83,6 +173,13 @@ const levenshtein = (a: string, b: string): number => {
83
173
  * "x y z" triple.
84
174
  * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
85
175
  * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
176
+ * @attribute {string} material-overrides - Overrides material assignments on the bound node's
177
+ * render component, as a JSON object from selector to `pc-material` id — for example
178
+ * `{"name:CarPaint": "candy-red", "index:7": "smoked-glass"}`. A `name:X` key selects every mesh
179
+ * instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and
180
+ * wins over a name rule for the same instance. Assignments no rule matches keep their baseline
181
+ * materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to
182
+ * discover the names and indices a node offers.
86
183
  * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
87
184
  * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
88
185
  * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
@@ -126,6 +223,20 @@ class NodeElement extends EntityBaseElement {
126
223
  /** The authored values displaced by this element's overrides, captured per property. */
127
224
  private _authored: AuthoredState = {};
128
225
 
226
+ /**
227
+ * The model-authored render component of the bound node, recorded at bind — before child
228
+ * decorations build — so a render component added later by a child `pc-render` can never
229
+ * become the override target. `null` when the bound node has none.
230
+ */
231
+ private _authoredRender: RenderComponent | null = null;
232
+
233
+ /**
234
+ * The baseline assignments displaced by the material overrides, captured for every mesh
235
+ * instance when the first non-empty mapping applies and released when the mapping goes
236
+ * absent (restoring them) or the binding dissolves.
237
+ */
238
+ private _baseline: BaselineAssignment[] | null = null;
239
+
129
240
  // Override values. `null` means "no override": the authored value stays in force.
130
241
 
131
242
  private _enabled: boolean | null = null;
@@ -138,6 +249,8 @@ class NodeElement extends EntityBaseElement {
138
249
 
139
250
  private _tags: string[] | null = null;
140
251
 
252
+ private _materialOverrides: MaterialOverrides | null = null;
253
+
141
254
  /**
142
255
  * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
143
256
  * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
@@ -298,6 +411,7 @@ class NodeElement extends EntityBaseElement {
298
411
  this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
299
412
  this._state = 'bound';
300
413
  this._path = this._pathOf(target, hostEntity);
414
+ this._authoredRender = target.render ?? null;
301
415
 
302
416
  this._applyOverrides();
303
417
  this._onReady();
@@ -333,6 +447,7 @@ class NodeElement extends EntityBaseElement {
333
447
  this._entity = null;
334
448
  this._path = null;
335
449
  this._authored = {};
450
+ this._authoredRender = null;
336
451
 
337
452
  // Component decorations come off through the same hook the host-ready cycle uses. A
338
453
  // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
@@ -357,6 +472,9 @@ class NodeElement extends EntityBaseElement {
357
472
  this._entity = null;
358
473
  this._path = null;
359
474
  this._authored = {};
475
+ this._authoredRender = null;
476
+ // The mesh instances died with the entity - the capture is dropped, not restored
477
+ this._baseline = null;
360
478
  this._state = 'pending';
361
479
  this._resetReady();
362
480
  }
@@ -400,6 +518,9 @@ class NodeElement extends EntityBaseElement {
400
518
  if (this._tags !== null) {
401
519
  this.tags = this._tags;
402
520
  }
521
+ if (this._materialOverrides !== null) {
522
+ this._applyMaterialOverrides();
523
+ }
403
524
  }
404
525
 
405
526
  /**
@@ -426,6 +547,120 @@ class NodeElement extends EntityBaseElement {
426
547
  entity.tags.add(authored.tags);
427
548
  }
428
549
  this._authored = {};
550
+ this._restoreBaseline();
551
+ }
552
+
553
+ /**
554
+ * Applies the material mapping to the authored render component: parse the mapping's valid
555
+ * rules, capture the baseline on first application, then recompute every assignment from
556
+ * that baseline - name rules write over it, index rules write over them, so `index:` wins -
557
+ * and assign whatever changed. An absent mapping, or one with no valid rules, restores the
558
+ * baseline instead. Called while bound, from `_applyOverrides` and the property setter.
559
+ */
560
+ private _applyMaterialOverrides() {
561
+ const label = `pc-node '${this._name}'`;
562
+
563
+ const rules = this._materialOverrides ? parseMaterialRules(this._materialOverrides, label) : [];
564
+ if (rules.length === 0) {
565
+ this._restoreBaseline();
566
+ return;
567
+ }
568
+
569
+ if (!this._baseline) {
570
+ if (!this._authoredRender) {
571
+ console.warn(
572
+ `${label} is bound to a node without an authored render component - material-overrides ignored`
573
+ );
574
+ return;
575
+ }
576
+ this._baseline = this._authoredRender.meshInstances.map((meshInstance) => ({
577
+ meshInstance,
578
+ material: (meshInstance.material as Material | null) ?? null,
579
+ name: meshInstance.material?.name ?? null
580
+ }));
581
+ }
582
+
583
+ const baseline = this._baseline;
584
+
585
+ /** Resolves a replacement id, warning when it does not resolve. */
586
+ const resolveReplacement = (id: string): Material | null => {
587
+ const material = MaterialElement.get(id);
588
+ if (!material) {
589
+ console.warn(`${label} material-overrides could not resolve pc-material '${id}' - rule ignored`);
590
+ }
591
+ return material ?? null;
592
+ };
593
+
594
+ // Recompute the whole list from the baseline: name rules write over it, index rules
595
+ // write over them. Recomputing makes mapping edits order-independent, and a rule whose
596
+ // replacement does not resolve simply leaves the layer below it in force.
597
+ const resolved = baseline.map((assignment) => assignment.material);
598
+
599
+ for (const rule of rules) {
600
+ if (rule.kind !== 'name') {
601
+ continue;
602
+ }
603
+ const material = resolveReplacement(rule.id);
604
+ if (!material) {
605
+ continue;
606
+ }
607
+ let matched = false;
608
+ baseline.forEach((assignment, index) => {
609
+ if (assignment.name === rule.name) {
610
+ resolved[index] = material;
611
+ matched = true;
612
+ }
613
+ });
614
+ if (!matched) {
615
+ const names = baseline.map((assignment) => `'${assignment.name}'`).join(', ');
616
+ console.warn(
617
+ `${label} material-overrides 'name:${rule.name}' matches no assignment - ` +
618
+ `baseline names: ${names || '(none)'}`
619
+ );
620
+ }
621
+ }
622
+
623
+ for (const rule of rules) {
624
+ if (rule.kind !== 'index') {
625
+ continue;
626
+ }
627
+ if (rule.index >= baseline.length) {
628
+ console.warn(
629
+ `${label} material-overrides 'index:${rule.index}' is out of range - ` +
630
+ `${baseline.length} assignment(s)`
631
+ );
632
+ continue;
633
+ }
634
+ const material = resolveReplacement(rule.id);
635
+ if (material) {
636
+ resolved[rule.index] = material;
637
+ }
638
+ }
639
+
640
+ baseline.forEach((assignment, index) => {
641
+ // The engine setter rebuilds material and shader state even for a redundant write,
642
+ // so only actual changes are assigned
643
+ if (assignment.meshInstance.material !== resolved[index]) {
644
+ assignment.meshInstance.material = resolved[index] as Material;
645
+ }
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Restores every baseline assignment the material overrides displaced and releases the
651
+ * capture, so the next non-empty mapping captures afresh. Safe to call without a capture.
652
+ */
653
+ private _restoreBaseline() {
654
+ const baseline = this._baseline;
655
+ if (!baseline) {
656
+ return;
657
+ }
658
+ this._baseline = null;
659
+ for (const assignment of baseline) {
660
+ if (assignment.meshInstance.material !== assignment.material) {
661
+ assignment.meshInstance.material = assignment.material as Material;
662
+ }
663
+ }
429
664
  }
430
665
 
431
666
  /**
@@ -659,8 +894,43 @@ class NodeElement extends EntityBaseElement {
659
894
  return this._tags;
660
895
  }
661
896
 
897
+ /**
898
+ * Sets the material overrides: a sparse mapping from selector to `pc-material` id, applied
899
+ * to the bound node's authored render component. A `name:X` key selects every mesh instance
900
+ * whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins
901
+ * over a name rule for the same instance. Assignments no rule matches keep their baseline
902
+ * materials. `null` clears the mapping, restoring every baseline assignment.
903
+ * @param value - The mapping, or `null`.
904
+ */
905
+ set materialOverrides(value: MaterialOverrides | null) {
906
+ // Copied and frozen: later caller mutation of the passed object must not silently
907
+ // disagree with the mapping the element applied
908
+ this._materialOverrides = value === null ? null : Object.freeze({ ...value });
909
+ if (this._state === 'bound') {
910
+ this._applyMaterialOverrides();
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Gets the material overrides.
916
+ * @returns The mapping, or `null` while no override is set.
917
+ */
918
+ get materialOverrides(): MaterialOverrides | null {
919
+ return this._materialOverrides;
920
+ }
921
+
662
922
  static get observedAttributes() {
663
- return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
923
+ return [
924
+ 'enabled',
925
+ 'index',
926
+ 'material-overrides',
927
+ 'name',
928
+ 'position',
929
+ 'rotation',
930
+ 'scale',
931
+ 'tags',
932
+ ...POINTER_ATTRIBUTES
933
+ ];
664
934
  }
665
935
 
666
936
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -684,6 +954,10 @@ class NodeElement extends EntityBaseElement {
684
954
  }
685
955
  }
686
956
  break;
957
+ case 'material-overrides':
958
+ this.materialOverrides =
959
+ newValue === null ? null : parseMaterialOverridesAttribute(newValue, `pc-node '${this._name}'`);
960
+ break;
687
961
  case 'name':
688
962
  this.name = newValue ?? '';
689
963
  break;
@@ -713,3 +987,4 @@ class NodeElement extends EntityBaseElement {
713
987
  customElements.define('pc-node', NodeElement);
714
988
 
715
989
  export { NodeElement };
990
+ export type { MaterialOverrides };