@playcanvas/web-components 0.13.1 → 0.15.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,11 @@
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
+ import { buildDescendantEntities } from './entity';
5
6
  import type { EntityElement } from './entity';
6
7
  import { EntityBaseElement, POINTER_ATTRIBUTES } from './entity-base';
8
+ import { MaterialElement } from './material';
7
9
  import { ModelElement } from './model';
8
10
  import { parseBool, parseTags, parseVec3 } from './parse';
9
11
 
@@ -27,6 +29,95 @@ type AuthoredState = {
27
29
  tags?: string[];
28
30
  };
29
31
 
32
+ /**
33
+ * A sparse mapping from selector to `pc-material` id, as carried by the `material-overrides`
34
+ * attribute and `materialOverrides` property. A `name:X` key selects every mesh instance of the
35
+ * bound node's render component whose baseline material is named `X`; an `index:N` key selects
36
+ * mesh instance `N` and wins over a name rule for the same instance.
37
+ */
38
+ type MaterialOverrides = Readonly<Record<string, string>>;
39
+
40
+ /**
41
+ * One baseline assignment, captured for every mesh instance of the authored render component
42
+ * when the first material override applies: the mesh instance, the material it displaced, and
43
+ * that material's name at capture time — the name `name:` selectors match, immune to later
44
+ * renames. `material` is `null` when a script had already cleared the assignment.
45
+ */
46
+ type BaselineAssignment = {
47
+ meshInstance: MeshInstance;
48
+ material: Material | null;
49
+ name: string | null;
50
+ };
51
+
52
+ /** One selector of a material-overrides mapping, in parsed form. */
53
+ type MaterialRule = { kind: 'name'; name: string; id: string } | { kind: 'index'; index: number; id: string };
54
+
55
+ /**
56
+ * Parses one mapping into its valid rules, warning for each entry that is not one: an unknown
57
+ * or missing selector prefix, an empty `name:` value, an `index:` value that is not a
58
+ * non-negative integer, or a replacement id that is not a non-empty string. An invalid rule
59
+ * behaves exactly as if absent from the mapping.
60
+ *
61
+ * @param overrides - The mapping to parse.
62
+ * @param label - The element description for warnings.
63
+ * @returns The valid rules.
64
+ */
65
+ const parseMaterialRules = (overrides: MaterialOverrides, label: string): MaterialRule[] => {
66
+ const rules: MaterialRule[] = [];
67
+ for (const [selector, id] of Object.entries(overrides)) {
68
+ if (typeof id !== 'string' || id === '') {
69
+ console.warn(`${label} material-overrides '${selector}' needs a pc-material id - rule ignored`);
70
+ } else if (selector.startsWith('name:')) {
71
+ // The text after the prefix is the selector value, exactly as written - a material
72
+ // name may legitimately begin or end with whitespace
73
+ const name = selector.slice('name:'.length);
74
+ if (name === '') {
75
+ console.warn(`${label} material-overrides 'name:' selector is empty - rule ignored`);
76
+ } else {
77
+ rules.push({ kind: 'name', name, id });
78
+ }
79
+ } else if (selector.startsWith('index:')) {
80
+ // Whitespace around the number is tolerated; Number('') is 0, so blank means NaN
81
+ const text = selector.slice('index:'.length).trim();
82
+ const index = text === '' ? NaN : Number(text);
83
+ if (!Number.isInteger(index) || index < 0) {
84
+ console.warn(
85
+ `${label} material-overrides '${selector}' is not a non-negative integer index - rule ignored`
86
+ );
87
+ } else {
88
+ rules.push({ kind: 'index', index, id });
89
+ }
90
+ } else {
91
+ console.warn(`${label} material-overrides '${selector}' has no 'name:' or 'index:' prefix - rule ignored`);
92
+ }
93
+ }
94
+ return rules;
95
+ };
96
+
97
+ /**
98
+ * Parses the material-overrides attribute text. Anything but a JSON object — malformed JSON, an
99
+ * array, a primitive — warns and yields `null`, the absent mapping: a stale mapping must not
100
+ * survive an attribute value the DOM no longer represents.
101
+ *
102
+ * @param text - The attribute text.
103
+ * @param label - The element description for warnings.
104
+ * @returns The mapping, or `null`.
105
+ */
106
+ const parseMaterialOverridesAttribute = (text: string, label: string): MaterialOverrides | null => {
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(text);
110
+ } catch (error) {
111
+ console.warn(`${label} material-overrides is not valid JSON - treated as absent: ${(error as Error).message}`);
112
+ return null;
113
+ }
114
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
115
+ console.warn(`${label} material-overrides must be a JSON object - treated as absent`);
116
+ return null;
117
+ }
118
+ return parsed as MaterialOverrides;
119
+ };
120
+
30
121
  /**
31
122
  * Computes the Levenshtein distance between two strings, for near-miss suggestions in the
32
123
  * resolution warnings.
@@ -83,6 +174,13 @@ const levenshtein = (a: string, b: string): number => {
83
174
  * "x y z" triple.
84
175
  * @attribute {string} scale - Overrides the node's local scale, as an "x y z" triple.
85
176
  * @attribute {string} tags - Overrides the node's tags, separated by spaces or commas.
177
+ * @attribute {string} material-overrides - Overrides material assignments on the bound node's
178
+ * render component, as a JSON object from selector to `pc-material` id — for example
179
+ * `{"name:CarPaint": "candy-red", "index:7": "smoked-glass"}`. A `name:X` key selects every mesh
180
+ * instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and
181
+ * wins over a name rule for the same instance. Assignments no rule matches keep their baseline
182
+ * materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to
183
+ * discover the names and indices a node offers.
86
184
  * @attribute {string} onpointerenter - Script to run when the pointer moves onto the node.
87
185
  * @attribute {string} onpointerleave - Script to run when the pointer moves off the node.
88
186
  * @attribute {string} onpointermove - Script to run when the pointer moves over the node.
@@ -126,6 +224,20 @@ class NodeElement extends EntityBaseElement {
126
224
  /** The authored values displaced by this element's overrides, captured per property. */
127
225
  private _authored: AuthoredState = {};
128
226
 
227
+ /**
228
+ * The model-authored render component of the bound node, recorded at bind — before child
229
+ * decorations build — so a render component added later by a child `pc-render` can never
230
+ * become the override target. `null` when the bound node has none.
231
+ */
232
+ private _authoredRender: RenderComponent | null = null;
233
+
234
+ /**
235
+ * The baseline assignments displaced by the material overrides, captured for every mesh
236
+ * instance when the first non-empty mapping applies and released when the mapping goes
237
+ * absent (restoring them) or the binding dissolves.
238
+ */
239
+ private _baseline: BaselineAssignment[] | null = null;
240
+
129
241
  // Override values. `null` means "no override": the authored value stays in force.
130
242
 
131
243
  private _enabled: boolean | null = null;
@@ -138,6 +250,8 @@ class NodeElement extends EntityBaseElement {
138
250
 
139
251
  private _tags: string[] | null = null;
140
252
 
253
+ private _materialOverrides: MaterialOverrides | null = null;
254
+
141
255
  /**
142
256
  * The binding state: `pending` until the host instantiates and `name` resolves, `bound`
143
257
  * once decorated, `missing`/`ambiguous`/`duplicate` when resolution failed (each also
@@ -298,6 +412,7 @@ class NodeElement extends EntityBaseElement {
298
412
  this._destroyHandle = target.once('destroy', this._onEntityDestroy, this);
299
413
  this._state = 'bound';
300
414
  this._path = this._pathOf(target, hostEntity);
415
+ this._authoredRender = target.render ?? null;
301
416
 
302
417
  this._applyOverrides();
303
418
  this._onReady();
@@ -333,6 +448,7 @@ class NodeElement extends EntityBaseElement {
333
448
  this._entity = null;
334
449
  this._path = null;
335
450
  this._authored = {};
451
+ this._authoredRender = null;
336
452
 
337
453
  // Component decorations come off through the same hook the host-ready cycle uses. A
338
454
  // dissolve that never rebinds fires no ready event, so the sweep is explicit - after
@@ -357,6 +473,9 @@ class NodeElement extends EntityBaseElement {
357
473
  this._entity = null;
358
474
  this._path = null;
359
475
  this._authored = {};
476
+ this._authoredRender = null;
477
+ // The mesh instances died with the entity - the capture is dropped, not restored
478
+ this._baseline = null;
360
479
  this._state = 'pending';
361
480
  this._resetReady();
362
481
  }
@@ -372,13 +491,7 @@ class NodeElement extends EntityBaseElement {
372
491
  if (!app) {
373
492
  return;
374
493
  }
375
- const childEntities = this.querySelectorAll<EntityElement>('pc-entity');
376
- childEntities.forEach((child) => {
377
- child._createEntity(app);
378
- });
379
- childEntities.forEach((child) => {
380
- child._buildHierarchy(app);
381
- });
494
+ buildDescendantEntities(this, app);
382
495
  }
383
496
 
384
497
  /**
@@ -400,6 +513,9 @@ class NodeElement extends EntityBaseElement {
400
513
  if (this._tags !== null) {
401
514
  this.tags = this._tags;
402
515
  }
516
+ if (this._materialOverrides !== null) {
517
+ this._applyMaterialOverrides();
518
+ }
403
519
  }
404
520
 
405
521
  /**
@@ -426,6 +542,120 @@ class NodeElement extends EntityBaseElement {
426
542
  entity.tags.add(authored.tags);
427
543
  }
428
544
  this._authored = {};
545
+ this._restoreBaseline();
546
+ }
547
+
548
+ /**
549
+ * Applies the material mapping to the authored render component: parse the mapping's valid
550
+ * rules, capture the baseline on first application, then recompute every assignment from
551
+ * that baseline - name rules write over it, index rules write over them, so `index:` wins -
552
+ * and assign whatever changed. An absent mapping, or one with no valid rules, restores the
553
+ * baseline instead. Called while bound, from `_applyOverrides` and the property setter.
554
+ */
555
+ private _applyMaterialOverrides() {
556
+ const label = `pc-node '${this._name}'`;
557
+
558
+ const rules = this._materialOverrides ? parseMaterialRules(this._materialOverrides, label) : [];
559
+ if (rules.length === 0) {
560
+ this._restoreBaseline();
561
+ return;
562
+ }
563
+
564
+ if (!this._baseline) {
565
+ if (!this._authoredRender) {
566
+ console.warn(
567
+ `${label} is bound to a node without an authored render component - material-overrides ignored`
568
+ );
569
+ return;
570
+ }
571
+ this._baseline = this._authoredRender.meshInstances.map((meshInstance) => ({
572
+ meshInstance,
573
+ material: (meshInstance.material as Material | null) ?? null,
574
+ name: meshInstance.material?.name ?? null
575
+ }));
576
+ }
577
+
578
+ const baseline = this._baseline;
579
+
580
+ /** Resolves a replacement id, warning when it does not resolve. */
581
+ const resolveReplacement = (id: string): Material | null => {
582
+ const material = MaterialElement.get(id);
583
+ if (!material) {
584
+ console.warn(`${label} material-overrides could not resolve pc-material '${id}' - rule ignored`);
585
+ }
586
+ return material ?? null;
587
+ };
588
+
589
+ // Recompute the whole list from the baseline: name rules write over it, index rules
590
+ // write over them. Recomputing makes mapping edits order-independent, and a rule whose
591
+ // replacement does not resolve simply leaves the layer below it in force.
592
+ const resolved = baseline.map((assignment) => assignment.material);
593
+
594
+ for (const rule of rules) {
595
+ if (rule.kind !== 'name') {
596
+ continue;
597
+ }
598
+ const material = resolveReplacement(rule.id);
599
+ if (!material) {
600
+ continue;
601
+ }
602
+ let matched = false;
603
+ baseline.forEach((assignment, index) => {
604
+ if (assignment.name === rule.name) {
605
+ resolved[index] = material;
606
+ matched = true;
607
+ }
608
+ });
609
+ if (!matched) {
610
+ const names = baseline.map((assignment) => `'${assignment.name}'`).join(', ');
611
+ console.warn(
612
+ `${label} material-overrides 'name:${rule.name}' matches no assignment - ` +
613
+ `baseline names: ${names || '(none)'}`
614
+ );
615
+ }
616
+ }
617
+
618
+ for (const rule of rules) {
619
+ if (rule.kind !== 'index') {
620
+ continue;
621
+ }
622
+ if (rule.index >= baseline.length) {
623
+ console.warn(
624
+ `${label} material-overrides 'index:${rule.index}' is out of range - ` +
625
+ `${baseline.length} assignment(s)`
626
+ );
627
+ continue;
628
+ }
629
+ const material = resolveReplacement(rule.id);
630
+ if (material) {
631
+ resolved[rule.index] = material;
632
+ }
633
+ }
634
+
635
+ baseline.forEach((assignment, index) => {
636
+ // The engine setter rebuilds material and shader state even for a redundant write,
637
+ // so only actual changes are assigned
638
+ if (assignment.meshInstance.material !== resolved[index]) {
639
+ assignment.meshInstance.material = resolved[index] as Material;
640
+ }
641
+ });
642
+ }
643
+
644
+ /**
645
+ * Restores every baseline assignment the material overrides displaced and releases the
646
+ * capture, so the next non-empty mapping captures afresh. Safe to call without a capture.
647
+ */
648
+ private _restoreBaseline() {
649
+ const baseline = this._baseline;
650
+ if (!baseline) {
651
+ return;
652
+ }
653
+ this._baseline = null;
654
+ for (const assignment of baseline) {
655
+ if (assignment.meshInstance.material !== assignment.material) {
656
+ assignment.meshInstance.material = assignment.material as Material;
657
+ }
658
+ }
429
659
  }
430
660
 
431
661
  /**
@@ -659,8 +889,43 @@ class NodeElement extends EntityBaseElement {
659
889
  return this._tags;
660
890
  }
661
891
 
892
+ /**
893
+ * Sets the material overrides: a sparse mapping from selector to `pc-material` id, applied
894
+ * to the bound node's authored render component. A `name:X` key selects every mesh instance
895
+ * whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins
896
+ * over a name rule for the same instance. Assignments no rule matches keep their baseline
897
+ * materials. `null` clears the mapping, restoring every baseline assignment.
898
+ * @param value - The mapping, or `null`.
899
+ */
900
+ set materialOverrides(value: MaterialOverrides | null) {
901
+ // Copied and frozen: later caller mutation of the passed object must not silently
902
+ // disagree with the mapping the element applied
903
+ this._materialOverrides = value === null ? null : Object.freeze({ ...value });
904
+ if (this._state === 'bound') {
905
+ this._applyMaterialOverrides();
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Gets the material overrides.
911
+ * @returns The mapping, or `null` while no override is set.
912
+ */
913
+ get materialOverrides(): MaterialOverrides | null {
914
+ return this._materialOverrides;
915
+ }
916
+
662
917
  static get observedAttributes() {
663
- return ['enabled', 'index', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
918
+ return [
919
+ 'enabled',
920
+ 'index',
921
+ 'material-overrides',
922
+ 'name',
923
+ 'position',
924
+ 'rotation',
925
+ 'scale',
926
+ 'tags',
927
+ ...POINTER_ATTRIBUTES
928
+ ];
664
929
  }
665
930
 
666
931
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -684,6 +949,10 @@ class NodeElement extends EntityBaseElement {
684
949
  }
685
950
  }
686
951
  break;
952
+ case 'material-overrides':
953
+ this.materialOverrides =
954
+ newValue === null ? null : parseMaterialOverridesAttribute(newValue, `pc-node '${this._name}'`);
955
+ break;
687
956
  case 'name':
688
957
  this.name = newValue ?? '';
689
958
  break;
@@ -713,3 +982,4 @@ class NodeElement extends EntityBaseElement {
713
982
  customElements.define('pc-node', NodeElement);
714
983
 
715
984
  export { NodeElement };
985
+ 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) {