@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/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 };
package/src/parse.ts CHANGED
@@ -28,7 +28,7 @@ import { CSS_COLORS } from './colors';
28
28
  * @param value - The value to split.
29
29
  * @param count - The required number of components.
30
30
  * @returns The parsed components, or `null`.
31
- * @ignore
31
+ * @internal
32
32
  */
33
33
  export const parseComponents = (value: string, count: number): number[] | null => {
34
34
  const components = value.trim().split(/\s+/).map(Number);
@@ -61,6 +61,7 @@ const cloneDefault = <T extends Color | Quat | Vec2 | Vec3 | Vec4 | null>(value:
61
61
  * @param value - The attribute value to parse (`null` when the attribute is absent).
62
62
  * @param defaultValue - The value to use when the attribute is absent or removed.
63
63
  * @returns The parsed boolean.
64
+ * @internal
64
65
  */
65
66
  export const parseBool = (value: string | null, defaultValue: boolean): boolean => {
66
67
  return value === null ? defaultValue : value !== 'false';
@@ -77,6 +78,7 @@ export const parseBool = (value: string | null, defaultValue: boolean): boolean
77
78
  * @param defaultValue - The value to use when the attribute is absent or invalid.
78
79
  * @param attribute - The attribute name, used in the warning message.
79
80
  * @returns The parsed Color object.
81
+ * @internal
80
82
  */
81
83
  export const parseColor = <T extends Color | null>(
82
84
  value: string | null,
@@ -129,6 +131,7 @@ export const parseColor = <T extends Color | null>(
129
131
  * @param defaultValue - The value to use when the attribute is absent or invalid.
130
132
  * @param attribute - The attribute name, used in the warning message.
131
133
  * @returns The resolved enum name.
134
+ * @internal
132
135
  */
133
136
  export const parseEnum = <T extends string>(
134
137
  value: string | null,
@@ -158,6 +161,7 @@ export const parseEnum = <T extends string>(
158
161
  * @param defaultValue - The value to use when the attribute is absent or invalid.
159
162
  * @param attribute - The attribute name, used in the warning message.
160
163
  * @returns The parsed number.
164
+ * @internal
161
165
  */
162
166
  export const parseNumber = <T extends number | null>(
163
167
  value: string | null,
@@ -187,6 +191,7 @@ export const parseNumber = <T extends number | null>(
187
191
  * @param defaultValue - The value to use when the attribute is absent or invalid.
188
192
  * @param attribute - The attribute name, used in the warning message.
189
193
  * @returns The parsed Quat object.
194
+ * @internal
190
195
  */
191
196
  export const parseQuat = <T extends Quat | null>(
192
197
  value: string | null,
@@ -217,6 +222,7 @@ export const parseQuat = <T extends Quat | null>(
217
222
  * @param value - The attribute value to parse (`null` when the attribute is absent).
218
223
  * @param defaultValue - The value to use when the attribute is absent or removed.
219
224
  * @returns The parsed tag names.
225
+ * @internal
220
226
  */
221
227
  export const parseTags = (value: string | null, defaultValue: string[] = []): string[] => {
222
228
  if (value === null) {
@@ -239,6 +245,7 @@ export const parseTags = (value: string | null, defaultValue: string[] = []): st
239
245
  * @param defaultValue - The value to use when the attribute is absent or invalid.
240
246
  * @param attribute - The attribute name, used in the warning message.
241
247
  * @returns The parsed Vec2 object.
248
+ * @internal
242
249
  */
243
250
  export const parseVec2 = <T extends Vec2 | null>(
244
251
  value: string | null,
@@ -267,6 +274,7 @@ export const parseVec2 = <T extends Vec2 | null>(
267
274
  * @param defaultValue - The value to use when the attribute is absent or invalid.
268
275
  * @param attribute - The attribute name, used in the warning message.
269
276
  * @returns The parsed Vec3 object.
277
+ * @internal
270
278
  */
271
279
  export const parseVec3 = <T extends Vec3 | null>(
272
280
  value: string | null,
@@ -295,6 +303,7 @@ export const parseVec3 = <T extends Vec3 | null>(
295
303
  * @param defaultValue - The value to use when the attribute is absent or invalid.
296
304
  * @param attribute - The attribute name, used in the warning message.
297
305
  * @returns The parsed Vec4 object.
306
+ * @internal
298
307
  */
299
308
  export const parseVec4 = <T extends Vec4 | null>(
300
309
  value: string | null,
@@ -321,6 +330,7 @@ export const parseVec4 = <T extends Vec4 | null>(
321
330
  *
322
331
  * @param ref - The reference string to resolve.
323
332
  * @returns The resolved entity, or `null`.
333
+ * @internal
324
334
  */
325
335
  export const getEntity = (ref: string): Entity | null => {
326
336
  if (!ref) {