@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.
@@ -544,6 +544,11 @@
544
544
  "description": "The UV channel the metalness map samples.",
545
545
  "values": []
546
546
  },
547
+ {
548
+ "name": "name",
549
+ "description": "The name of the material - the label shown wherever materials surface by name, such as profilers, GPU captures and the assignments `pc-model.hierarchy()` reports.",
550
+ "values": []
551
+ },
547
552
  {
548
553
  "name": "normal-map",
549
554
  "description": "The id of the `pc-asset` used as the normal map.",
@@ -698,7 +703,7 @@
698
703
  },
699
704
  {
700
705
  "name": "pc-model",
701
- "description": "The ModelElement interface provides properties and methods for manipulating\n[`<pc-model>`](https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/) elements.\nThe ModelElement interface also inherits the properties and methods of the\nHTMLElement interface.\n\nThe element becomes ready once its container asset has loaded and the instantiated hierarchy has\nbeen added to the scene — `entity` is non-null by then. A failed load also settles readiness,\nwith `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen\nfor `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness\nand instantiates anew, so a `ready()` obtained after the change resolves against the new\nhierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never\nbecomes ready.\n\n---\n\n\n### **Events:**\n - **error** - Fired when the container asset fails to load, with the engine's error in `message`. Does not bubble. The element still becomes ready — readiness means the load settled, not that it succeeded.\n- **load** - Fired each time a container asset finishes instantiating, including re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a capture-phase listener on an ancestor.\n- **ready** - Fired when the element is fully initialized — once per readiness cycle, so an element that is torn down and re-initialized (for example by removing and re-inserting it) fires it again. Bubbles and is composed.\n\n### **Methods:**\n - **ready(): __** - Returns a promise that resolves with this element when it's ready. This is the low-level\nprimitive underlying whenReady, which is the recommended way to wait for elements.\n\nReadiness tracks the element's current lifecycle: once a ready element is torn down (for\nexample by removing it from the document), this returns a fresh promise that resolves when\nthe element is next ready. A promise obtained earlier stays resolved — call this again\nafter re-inserting an element rather than reusing a promise from before its removal.",
706
+ "description": "The ModelElement interface provides properties and methods for manipulating\n[`<pc-model>`](https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/) elements.\nThe ModelElement interface also inherits the properties and methods of the\nHTMLElement interface.\n\nThe element becomes ready once its container asset has loaded and the instantiated hierarchy has\nbeen added to the scene — `entity` is non-null by then. A failed load also settles readiness,\nwith `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen\nfor `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness\nand instantiates anew, so a `ready()` obtained after the change resolves against the new\nhierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never\nbecomes ready.\n\n---\n\n\n### **Events:**\n - **error** - Fired when the container asset fails to load, with the engine's error in `message`. Does not bubble. The element still becomes ready — readiness means the load settled, not that it succeeded.\n- **load** - Fired each time a container asset finishes instantiating, including re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a capture-phase listener on an ancestor.\n- **ready** - Fired when the element is fully initialized — once per readiness cycle, so an element that is torn down and re-initialized (for example by removing and re-inserting it) fires it again. Bubbles and is composed.\n\n### **Methods:**\n - **hierarchy(): __** - Returns a snapshot of the instantiated node tree, or `null` while there is none (the\ncontainer asset has not loaded, or the element has left the document). One call grounds a\nsession — a browser console, a test, an agent — in the vocabulary `pc-node` binding\nresolves against: the instantiated names (HierarchyNode.name), paths, match\nindices, attached component types and the material assignments of render components\n(HierarchyNode.materials). `String(...)` of the result, or of any node in it,\nis the printable form.\n\nThe snapshot is plain data, computed afresh each call: it does not follow later changes\nto the hierarchy, and mutating it changes nothing.\n- **ready(): __** - Returns a promise that resolves with this element when it's ready. This is the low-level\nprimitive underlying whenReady, which is the recommended way to wait for elements.\n\nReadiness tracks the element's current lifecycle: once a ready element is torn down (for\nexample by removing it from the document), this returns a fresh promise that resolves when\nthe element is next ready. A promise obtained earlier stays resolved — call this again\nafter re-inserting an element rather than reusing a promise from before its removal.",
702
707
  "attributes": [
703
708
  {
704
709
  "name": "asset",
@@ -749,6 +754,11 @@
749
754
  "description": "Which match to bind when `name` matches more than one node, 0-based in depth-first order. Optional for a unique match; required for an ambiguous one.",
750
755
  "values": []
751
756
  },
757
+ {
758
+ "name": "material-overrides",
759
+ "description": "Overrides material assignments on the bound node's render component, as a JSON object from selector to `pc-material` id — for example `{\"name:CarPaint\": \"candy-red\", \"index:7\": \"smoked-glass\"}`. A `name:X` key selects every mesh instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins over a name rule for the same instance. Assignments no rule matches keep their baseline materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to discover the names and indices a node offers.",
760
+ "values": []
761
+ },
752
762
  {
753
763
  "name": "name",
754
764
  "description": "The name of the node to bind, resolved within the nearest ancestor `pc-model` (or `pc-node`) once it has instantiated.",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
3
3
  "name": "@playcanvas/web-components",
4
- "version": "0.13.1",
4
+ "version": "0.14.0",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {
@@ -785,6 +785,11 @@
785
785
  "description": "The UV channel the metalness map samples.",
786
786
  "value": { "type": "number", "default": "0" }
787
787
  },
788
+ {
789
+ "name": "name",
790
+ "description": "The name of the material - the label shown wherever materials surface by name, such as profilers, GPU captures and the assignments `pc-model.hierarchy()` reports.",
791
+ "value": { "type": "string" }
792
+ },
788
793
  {
789
794
  "name": "normal-map",
790
795
  "description": "The id of the `pc-asset` used as the normal map.",
@@ -1162,6 +1167,10 @@
1162
1167
  "name": "metalnessMapUv",
1163
1168
  "description": "Gets the UV channel the metalness map samples."
1164
1169
  },
1170
+ {
1171
+ "name": "name",
1172
+ "description": "Gets the name of the material - the label shown wherever materials surface by name, such\nas profilers, GPU captures and the assignments `pc-model.hierarchy()` reports. Purely a\nlabel: element references resolve through `id`."
1173
+ },
1165
1174
  {
1166
1175
  "name": "normalMap",
1167
1176
  "description": "Gets the id of the `pc-asset` used as the normal map."
@@ -1284,7 +1293,7 @@
1284
1293
  },
1285
1294
  {
1286
1295
  "name": "pc-model",
1287
- "description": "The ModelElement interface provides properties and methods for manipulating\n[`<pc-model>`](https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/) elements.\nThe ModelElement interface also inherits the properties and methods of the\nHTMLElement interface.\n\nThe element becomes ready once its container asset has loaded and the instantiated hierarchy has\nbeen added to the scene — `entity` is non-null by then. A failed load also settles readiness,\nwith `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen\nfor `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness\nand instantiates anew, so a `ready()` obtained after the change resolves against the new\nhierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never\nbecomes ready.\n\n---\n\n\n### **Events:**\n - **error** - Fired when the container asset fails to load, with the engine's error in `message`. Does not bubble. The element still becomes ready — readiness means the load settled, not that it succeeded.\n- **load** - Fired each time a container asset finishes instantiating, including re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a capture-phase listener on an ancestor.\n- **ready** - Fired when the element is fully initialized — once per readiness cycle, so an element that is torn down and re-initialized (for example by removing and re-inserting it) fires it again. Bubbles and is composed.\n\n### **Methods:**\n - **ready(): __** - Returns a promise that resolves with this element when it's ready. This is the low-level\nprimitive underlying whenReady, which is the recommended way to wait for elements.\n\nReadiness tracks the element's current lifecycle: once a ready element is torn down (for\nexample by removing it from the document), this returns a fresh promise that resolves when\nthe element is next ready. A promise obtained earlier stays resolved — call this again\nafter re-inserting an element rather than reusing a promise from before its removal.",
1296
+ "description": "The ModelElement interface provides properties and methods for manipulating\n[`<pc-model>`](https://developer.playcanvas.com/user-manual/web-components/tags/pc-model/) elements.\nThe ModelElement interface also inherits the properties and methods of the\nHTMLElement interface.\n\nThe element becomes ready once its container asset has loaded and the instantiated hierarchy has\nbeen added to the scene — `entity` is non-null by then. A failed load also settles readiness,\nwith `entity` remaining `null`: readiness means the load settled, not that it succeeded — listen\nfor `error`, or check `entity`, to tell the outcomes apart. Changing `asset` re-arms readiness\nand instantiates anew, so a `ready()` obtained after the change resolves against the new\nhierarchy. A `pc-model` outside a `pc-app`, or referencing an unknown asset id, warns and never\nbecomes ready.\n\n---\n\n\n### **Events:**\n - **error** - Fired when the container asset fails to load, with the engine's error in `message`. Does not bubble. The element still becomes ready — readiness means the load settled, not that it succeeded.\n- **load** - Fired each time a container asset finishes instantiating, including re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a capture-phase listener on an ancestor.\n- **ready** - Fired when the element is fully initialized — once per readiness cycle, so an element that is torn down and re-initialized (for example by removing and re-inserting it) fires it again. Bubbles and is composed.\n\n### **Methods:**\n - **hierarchy(): __** - Returns a snapshot of the instantiated node tree, or `null` while there is none (the\ncontainer asset has not loaded, or the element has left the document). One call grounds a\nsession — a browser console, a test, an agent — in the vocabulary `pc-node` binding\nresolves against: the instantiated names (HierarchyNode.name), paths, match\nindices, attached component types and the material assignments of render components\n(HierarchyNode.materials). `String(...)` of the result, or of any node in it,\nis the printable form.\n\nThe snapshot is plain data, computed afresh each call: it does not follow later changes\nto the hierarchy, and mutating it changes nothing.\n- **ready(): __** - Returns a promise that resolves with this element when it's ready. This is the low-level\nprimitive underlying whenReady, which is the recommended way to wait for elements.\n\nReadiness tracks the element's current lifecycle: once a ready element is torn down (for\nexample by removing it from the document), this returns a fresh promise that resolves when\nthe element is next ready. A promise obtained earlier stays resolved — call this again\nafter re-inserting an element rather than reusing a promise from before its removal.",
1288
1297
  "doc-url": "",
1289
1298
  "attributes": [
1290
1299
  {
@@ -1422,6 +1431,11 @@
1422
1431
  "description": "Which match to bind when `name` matches more than one node, 0-based in depth-first order. Optional for a unique match; required for an ambiguous one.",
1423
1432
  "value": { "type": "string" }
1424
1433
  },
1434
+ {
1435
+ "name": "material-overrides",
1436
+ "description": "Overrides material assignments on the bound node's render component, as a JSON object from selector to `pc-material` id — for example `{\"name:CarPaint\": \"candy-red\", \"index:7\": \"smoked-glass\"}`. A `name:X` key selects every mesh instance whose baseline material is named `X`; an `index:N` key selects mesh instance `N` and wins over a name rule for the same instance. Assignments no rule matches keep their baseline materials, and removing the attribute restores all of them. Use `pc-model.hierarchy()` to discover the names and indices a node offers.",
1437
+ "value": { "type": "string" }
1438
+ },
1425
1439
  {
1426
1440
  "name": "name",
1427
1441
  "description": "The name of the node to bind, resolved within the nearest ancestor `pc-model` (or `pc-node`) once it has instantiated.",
@@ -1532,6 +1546,11 @@
1532
1546
  "description": "Gets which match to bind.",
1533
1547
  "type": "number | null"
1534
1548
  },
1549
+ {
1550
+ "name": "materialOverrides",
1551
+ "description": "Gets the material overrides.",
1552
+ "type": "MaterialOverrides | null"
1553
+ },
1535
1554
  {
1536
1555
  "name": "name",
1537
1556
  "description": "Gets the name of the node to bind.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcanvas/web-components",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "author": "PlayCanvas <support@playcanvas.com>",
5
5
  "homepage": "https://playcanvas.com",
6
6
  "description": "Web Components for the PlayCanvas Engine",
@@ -96,9 +96,9 @@
96
96
  "custom-element-vs-code-integration": "1.5.0",
97
97
  "earcut": "3.2.3",
98
98
  "eslint": "9.39.5",
99
- "globals": "17.9.0",
99
+ "globals": "17.11.0",
100
100
  "jsdom": "30.0.1",
101
- "mediabunny": "1.53.0",
101
+ "mediabunny": "1.53.1",
102
102
  "opentype.js": "2.0.0",
103
103
  "playcanvas": "2.22.0-beta.12",
104
104
  "prettier": "3.9.6",
package/src/colors.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The CSS color keywords, lowercase name to hex value. Read by `parseColor` to accept color
3
+ * names as attribute values.
4
+ * @internal
5
+ */
1
6
  export const CSS_COLORS: Record<string, string> = {
2
7
  aliceblue: '#f0f8ff',
3
8
  antiquewhite: '#faebd7',
@@ -6,9 +6,9 @@ import { AsyncElement } from './async-element';
6
6
  /**
7
7
  * The attribute names of the inline `onpointer*` event handlers, shared by every element that
8
8
  * fronts an engine entity. Spread into `observedAttributes` by subclasses.
9
- * @ignore
9
+ * @internal
10
10
  */
11
- const POINTER_ATTRIBUTES = [
11
+ export const POINTER_ATTRIBUTES = [
12
12
  'onpointerenter',
13
13
  'onpointerleave',
14
14
  'onpointerdown',
@@ -133,4 +133,4 @@ class EntityBaseElement extends AsyncElement {
133
133
  }
134
134
  }
135
135
 
136
- export { EntityBaseElement, POINTER_ATTRIBUTES };
136
+ export { EntityBaseElement };
package/src/index.ts CHANGED
@@ -123,3 +123,5 @@ export {
123
123
  };
124
124
 
125
125
  export type { AsyncElementTagName } from './async-element';
126
+ export type { HierarchyMaterial, HierarchyNode } from './model';
127
+ export type { MaterialOverrides } from './node';
@@ -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
@@ -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;
@@ -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
@@ -3,6 +3,101 @@ import type { ContainerResource, Entity, EventHandle } from 'playcanvas';
3
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
@@ -235,3 +390,4 @@ class ModelElement extends AsyncElement {
235
390
  customElements.define('pc-model', ModelElement);
236
391
 
237
392
  export { ModelElement };
393
+ export type { HierarchyMaterial, HierarchyNode };