@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.
- package/dist/app.d.cts +35 -0
- package/dist/app.d.ts +35 -0
- package/dist/asset.d.cts +7 -1
- package/dist/asset.d.ts +7 -1
- package/dist/colors.d.cts +1 -1
- package/dist/colors.d.ts +1 -1
- package/dist/custom-elements.json +114 -1
- package/dist/entity-base.d.cts +1 -7
- package/dist/entity-base.d.ts +1 -7
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/loading-bar.d.cts +1 -35
- package/dist/loading-bar.d.ts +1 -35
- package/dist/material.d.cts +13 -0
- package/dist/material.d.ts +13 -0
- package/dist/model.d.cts +71 -0
- package/dist/model.d.ts +71 -0
- package/dist/node.d.cts +55 -0
- package/dist/node.d.ts +55 -0
- package/dist/parse.d.cts +1 -130
- package/dist/parse.d.ts +1 -130
- package/dist/pwc.cjs +506 -54
- package/dist/pwc.cjs.map +1 -1
- package/dist/pwc.js +506 -54
- package/dist/pwc.js.map +1 -1
- package/dist/pwc.min.js +1 -1
- package/dist/pwc.min.js.map +1 -1
- package/dist/pwc.min.mjs +1 -1
- package/dist/pwc.min.mjs.map +1 -1
- package/dist/pwc.mjs +506 -54
- package/dist/pwc.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +12 -2
- package/dist/web-types.json +22 -3
- package/package.json +3 -3
- package/src/app.ts +92 -19
- package/src/asset.ts +34 -2
- package/src/colors.ts +5 -0
- package/src/components/button-component.ts +7 -7
- package/src/components/element-component.ts +7 -7
- package/src/components/gsplat-component.ts +3 -3
- package/src/components/particlesystem-component.ts +7 -8
- package/src/components/script-component.ts +2 -2
- package/src/components/sound-slot.ts +2 -2
- package/src/entity-base.ts +3 -3
- package/src/index.ts +2 -0
- package/src/loading-bar.ts +2 -3
- package/src/material.ts +31 -2
- package/src/model.ts +158 -5
- package/src/node.ts +277 -2
- package/src/parse.ts +11 -1
- package/src/sky.ts +2 -3
package/src/loading-bar.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 {
|
|
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
|
|
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 [
|
|
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 };
|