@playcanvas/web-components 0.10.0 → 0.11.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/asset.ts CHANGED
@@ -99,6 +99,13 @@ const processBufferView = (
99
99
  * @attribute {number} pixels-per-unit - For a `sprite` asset, the number of pixels per world unit.
100
100
  * @attribute {'simple' | 'sliced' | 'tiled'} render-mode - For a `sprite` asset, how the sprite is
101
101
  * rendered when resized.
102
+ *
103
+ * @fires {Event} load - Fired each time the asset finishes loading, including a `lazy` asset
104
+ * loaded later and any subsequent reloads. Does not bubble — listen on this element, or use a
105
+ * capture-phase listener on an ancestor to observe every asset.
106
+ * @fires {ErrorEvent} error - Fired when the asset fails to load, with the engine's error in
107
+ * `message`. Does not bubble. The element still becomes ready — readiness means the load settled,
108
+ * not that it succeeded.
102
109
  */
103
110
  class AssetElement extends AsyncElement {
104
111
  private _lazy: boolean = false;
@@ -149,6 +156,16 @@ class AssetElement extends AsyncElement {
149
156
  this.destroyAsset();
150
157
  }
151
158
 
159
+ private _onAssetLoad() {
160
+ this.dispatchEvent(new Event('load'));
161
+ }
162
+
163
+ private _onAssetError(err: string | Error) {
164
+ this.dispatchEvent(new ErrorEvent('error', {
165
+ message: err instanceof Error ? err.message : String(err)
166
+ }));
167
+ }
168
+
152
169
  createAsset() {
153
170
  const id = this.getAttribute('id') || '';
154
171
  const src = this.getAttribute('src') || '';
@@ -186,6 +203,11 @@ class AssetElement extends AsyncElement {
186
203
  }
187
204
 
188
205
  this.asset.preload = !this._lazy;
206
+
207
+ // Forward the engine asset's load outcome as DOM events on this element, like <img>.
208
+ // Attached before the asset joins the registry, which is what starts a preloaded load.
209
+ this.asset.on('load', this._onAssetLoad, this);
210
+ this.asset.on('error', this._onAssetError, this);
189
211
  }
190
212
 
191
213
  /**
@@ -249,6 +271,9 @@ class AssetElement extends AsyncElement {
249
271
 
250
272
  destroyAsset() {
251
273
  if (this.asset) {
274
+ // A caller that keeps the Asset alive must not dispatch on a removed element
275
+ this.asset.off('load', this._onAssetLoad, this);
276
+ this.asset.off('error', this._onAssetError, this);
252
277
  // Deregister first so unload() can still notify the registry
253
278
  this.asset.registry?.remove(this.asset);
254
279
  this.asset.unload();
@@ -20,12 +20,22 @@ class AsyncElement extends HTMLElement {
20
20
  });
21
21
  }
22
22
 
23
- get closestApp(): AppElement {
24
- return this.parentElement?.closest('pc-app') as AppElement;
23
+ /**
24
+ * The nearest ancestor `<pc-app>` element, or `null` if this element has no `<pc-app>`
25
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
26
+ * @returns The closest app element, or `null`.
27
+ */
28
+ get closestApp(): AppElement | null {
29
+ return this.parentElement?.closest('pc-app') as AppElement | null ?? null;
25
30
  }
26
31
 
27
- get closestEntity(): EntityElement {
28
- return this.parentElement?.closest('pc-entity') as EntityElement;
32
+ /**
33
+ * The nearest ancestor `<pc-entity>` element, or `null` if this element has no `<pc-entity>`
34
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
35
+ * @returns The closest entity element, or `null`.
36
+ */
37
+ get closestEntity(): EntityElement | null {
38
+ return this.parentElement?.closest('pc-entity') as EntityElement | null ?? null;
29
39
  }
30
40
 
31
41
  /**
@@ -3,6 +3,11 @@ import { CameraComponent, Color, Vec4, GAMMA_NONE, GAMMA_SRGB, PROJECTION_ORTHOG
3
3
  import { ComponentElement } from './component';
4
4
  import { parseBool, parseColor, parseEnum, parseNumber, parseVec4 } from '../parse';
5
5
 
6
+ const projections = new Map<'perspective' | 'orthographic', number>([
7
+ ['perspective', PROJECTION_PERSPECTIVE],
8
+ ['orthographic', PROJECTION_ORTHOGRAPHIC]
9
+ ]);
10
+
6
11
  const tonemaps = new Map<'none' | 'linear' | 'filmic' | 'hejl' | 'aces' | 'aces2' | 'neutral', number>([
7
12
  ['none', TONEMAP_NONE],
8
13
  ['linear', TONEMAP_LINEAR],
@@ -46,7 +51,7 @@ class CameraComponentElement extends ComponentElement {
46
51
 
47
52
  private _nearClip = 0.1;
48
53
 
49
- private _orthographic = false;
54
+ private _projection: 'perspective' | 'orthographic' = 'perspective';
50
55
 
51
56
  private _orthoHeight = 10;
52
57
 
@@ -77,12 +82,12 @@ class CameraComponentElement extends ComponentElement {
77
82
  gammaCorrection: this._gamma === 'srgb' ? GAMMA_SRGB : GAMMA_NONE,
78
83
  horizontalFov: this._horizontalFov,
79
84
  nearClip: this._nearClip,
80
- projection: this._orthographic ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE,
85
+ projection: projections.get(this._projection) ?? PROJECTION_PERSPECTIVE,
81
86
  orthoHeight: this._orthoHeight,
82
87
  priority: this._priority,
83
88
  rect: this._rect,
84
89
  scissorRect: this._scissorRect,
85
- toneMapping: tonemaps.get(this._tonemap)
90
+ toneMapping: tonemaps.get(this._tonemap) ?? TONEMAP_NONE
86
91
  };
87
92
  }
88
93
 
@@ -352,25 +357,6 @@ class CameraComponentElement extends ComponentElement {
352
357
  return this._nearClip;
353
358
  }
354
359
 
355
- /**
356
- * Sets the orthographic projection of the camera.
357
- * @param value - The orthographic projection.
358
- */
359
- set orthographic(value) {
360
- this._orthographic = value;
361
- if (this.component) {
362
- this.component.projection = value ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE;
363
- }
364
- }
365
-
366
- /**
367
- * Gets the orthographic projection of the camera.
368
- * @returns The orthographic projection.
369
- */
370
- get orthographic(): boolean {
371
- return this._orthographic;
372
- }
373
-
374
360
  /**
375
361
  * Sets the orthographic height of the camera.
376
362
  * @param value - The orthographic height.
@@ -409,6 +395,25 @@ class CameraComponentElement extends ComponentElement {
409
395
  return this._priority;
410
396
  }
411
397
 
398
+ /**
399
+ * Sets the projection of the camera. Use `orthoHeight` to size an orthographic projection.
400
+ * @param value - The projection ('perspective' or 'orthographic').
401
+ */
402
+ set projection(value: 'perspective' | 'orthographic') {
403
+ this._projection = value;
404
+ if (this.component) {
405
+ this.component.projection = projections.get(value) ?? PROJECTION_PERSPECTIVE;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Gets the projection of the camera.
411
+ * @returns The projection.
412
+ */
413
+ get projection() {
414
+ return this._projection;
415
+ }
416
+
412
417
  /**
413
418
  * Sets the rect of the camera.
414
419
  * @param value - The rect.
@@ -481,9 +486,9 @@ class CameraComponentElement extends ComponentElement {
481
486
  'gamma',
482
487
  'horizontal-fov',
483
488
  'near-clip',
484
- 'orthographic',
485
489
  'ortho-height',
486
490
  'priority',
491
+ 'projection',
487
492
  'rect',
488
493
  'scissor-rect',
489
494
  'tonemap'
@@ -530,15 +535,15 @@ class CameraComponentElement extends ComponentElement {
530
535
  case 'near-clip':
531
536
  this.nearClip = parseNumber(newValue, 0.1, name);
532
537
  break;
533
- case 'orthographic':
534
- this.orthographic = parseBool(newValue, false);
535
- break;
536
538
  case 'ortho-height':
537
539
  this.orthoHeight = parseNumber(newValue, 10, name);
538
540
  break;
539
541
  case 'priority':
540
542
  this.priority = parseNumber(newValue, 0, name);
541
543
  break;
544
+ case 'projection':
545
+ this.projection = parseEnum(newValue, projections, 'perspective', name);
546
+ break;
542
547
  case 'rect':
543
548
  this.rect = parseVec4(newValue, new Vec4(0, 0, 1, 1), name);
544
549
  break;
@@ -73,12 +73,13 @@ class ComponentElement extends AsyncElement {
73
73
  }
74
74
 
75
75
  /**
76
- * The PlayCanvas component instance. Available once the element is ready await
77
- * {@link whenReady} or the element's `ready()` promise before accessing it.
78
- * @returns The component instance.
76
+ * The PlayCanvas component instance. `null` until the element is ready, and also for an
77
+ * element that is not a descendant of a `<pc-entity>` — await {@link whenReady} or the
78
+ * element's `ready()` promise before accessing it.
79
+ * @returns The component instance, or `null`.
79
80
  */
80
- get component(): Component {
81
- return this._component!;
81
+ get component(): Component | null {
82
+ return this._component;
82
83
  }
83
84
 
84
85
  /**
@@ -1,7 +1,16 @@
1
1
  import { SCALEMODE_BLEND, SCALEMODE_NONE, ScreenComponent, Vec2 } from 'playcanvas';
2
2
 
3
3
  import { ComponentElement } from './component';
4
- import { parseBool, parseNumber, parseVec2 } from '../parse';
4
+ import { parseBool, parseEnum, parseNumber, parseVec2 } from '../parse';
5
+
6
+ // The engine's SCALEMODE_* constants are the strings 'none' and 'blend', so this map happens to be
7
+ // an identity. It is still the right shape: it supplies parseEnum's valid-name list, it is what the
8
+ // manifest generator reads the enum values from, and it keeps the attribute vocabulary independent
9
+ // of constants the engine is free to change.
10
+ const scaleModes = new Map<'none' | 'blend', string>([
11
+ ['none', SCALEMODE_NONE],
12
+ ['blend', SCALEMODE_BLEND]
13
+ ]);
5
14
 
6
15
  /**
7
16
  * The ScreenComponentElement interface provides properties and methods for manipulating
@@ -20,7 +29,7 @@ class ScreenComponentElement extends ComponentElement {
20
29
 
21
30
  private _priority = 0;
22
31
 
23
- private _blend = false;
32
+ private _scaleMode: 'none' | 'blend' = 'none';
24
33
 
25
34
  private _scaleBlend = 0.5;
26
35
 
@@ -35,7 +44,7 @@ class ScreenComponentElement extends ComponentElement {
35
44
  referenceResolution: this._referenceResolution,
36
45
  resolution: this._resolution,
37
46
  scaleBlend: this._scaleBlend,
38
- scaleMode: this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE,
47
+ scaleMode: scaleModes.get(this._scaleMode) ?? SCALEMODE_NONE,
39
48
  screenSpace: this._screenSpace
40
49
  };
41
50
  }
@@ -81,6 +90,12 @@ class ScreenComponentElement extends ComponentElement {
81
90
  return this._resolution;
82
91
  }
83
92
 
93
+ /**
94
+ * Sets how the screen's `resolution` and `referenceResolution` are weighted against each other
95
+ * when `scaleMode` is `blend`, from 0 (follow the resolution) to 1 (follow the reference
96
+ * resolution). Ignored while `scaleMode` is `none`.
97
+ * @param value - The scale blend factor.
98
+ */
84
99
  set scaleBlend(value: number) {
85
100
  this._scaleBlend = value;
86
101
  if (this.component) {
@@ -88,19 +103,34 @@ class ScreenComponentElement extends ComponentElement {
88
103
  }
89
104
  }
90
105
 
106
+ /**
107
+ * Gets how the screen's resolutions are weighted against each other.
108
+ * @returns The scale blend factor.
109
+ */
91
110
  get scaleBlend() {
92
111
  return this._scaleBlend;
93
112
  }
94
113
 
95
- set blend(value: boolean) {
96
- this._blend = value;
114
+ /**
115
+ * Sets how the screen scales its contents. `none` renders at `resolution` and ignores
116
+ * `referenceResolution`; `blend` scales between the two, weighted by `scaleBlend`, which is what
117
+ * keeps a UI laid out at one resolution usable at another. Requires `screenSpace` - the engine
118
+ * forces `none` on a world-space screen, which does not support scaling.
119
+ * @param value - The scale mode ('none' or 'blend').
120
+ */
121
+ set scaleMode(value: 'none' | 'blend') {
122
+ this._scaleMode = value;
97
123
  if (this.component) {
98
- this.component.scaleMode = this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE;
124
+ this.component.scaleMode = scaleModes.get(value) ?? SCALEMODE_NONE;
99
125
  }
100
126
  }
101
127
 
102
- get blend() {
103
- return this._blend;
128
+ /**
129
+ * Gets how the screen scales its contents.
130
+ * @returns The scale mode.
131
+ */
132
+ get scaleMode() {
133
+ return this._scaleMode;
104
134
  }
105
135
 
106
136
  set screenSpace(value: boolean) {
@@ -117,12 +147,12 @@ class ScreenComponentElement extends ComponentElement {
117
147
  static get observedAttributes() {
118
148
  return [
119
149
  ...super.observedAttributes,
120
- 'blend',
121
150
  'screen-space',
122
151
  'resolution',
123
152
  'reference-resolution',
124
153
  'priority',
125
- 'scale-blend'
154
+ 'scale-blend',
155
+ 'scale-mode'
126
156
  ];
127
157
  }
128
158
 
@@ -142,8 +172,8 @@ class ScreenComponentElement extends ComponentElement {
142
172
  case 'scale-blend':
143
173
  this.scaleBlend = parseNumber(newValue, 0.5, name);
144
174
  break;
145
- case 'blend':
146
- this.blend = parseBool(newValue, false);
175
+ case 'scale-mode':
176
+ this.scaleMode = parseEnum(newValue, scaleModes, 'none', name);
147
177
  break;
148
178
  case 'screen-space':
149
179
  this.screenSpace = parseBool(newValue, false);
@@ -99,7 +99,8 @@ class ScrollViewComponentElement extends ComponentElement {
99
99
  }
100
100
 
101
101
  /**
102
- * Sets whether horizontal scrolling is enabled.
102
+ * Sets whether scrolling along the horizontal axis is enabled. This is a toggle, unlike the
103
+ * `orientation` of a `<pc-scrollbar>`, for which `horizontal` is one of the accepted values.
103
104
  * @param value - Whether horizontal scrolling is enabled.
104
105
  */
105
106
  set horizontal(value: boolean) {
@@ -110,7 +111,7 @@ class ScrollViewComponentElement extends ComponentElement {
110
111
  }
111
112
 
112
113
  /**
113
- * Gets whether horizontal scrolling is enabled.
114
+ * Gets whether scrolling along the horizontal axis is enabled.
114
115
  * @returns Whether horizontal scrolling is enabled.
115
116
  */
116
117
  get horizontal() {
@@ -118,7 +119,8 @@ class ScrollViewComponentElement extends ComponentElement {
118
119
  }
119
120
 
120
121
  /**
121
- * Sets whether vertical scrolling is enabled.
122
+ * Sets whether scrolling along the vertical axis is enabled. This is a toggle, unlike the
123
+ * `orientation` of a `<pc-scrollbar>`, for which `vertical` is one of the accepted values.
122
124
  * @param value - Whether vertical scrolling is enabled.
123
125
  */
124
126
  set vertical(value: boolean) {
@@ -129,7 +131,7 @@ class ScrollViewComponentElement extends ComponentElement {
129
131
  }
130
132
 
131
133
  /**
132
- * Gets whether vertical scrolling is enabled.
134
+ * Gets whether scrolling along the vertical axis is enabled.
133
135
  * @returns Whether vertical scrolling is enabled.
134
136
  */
135
137
  get vertical() {
package/src/entity.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AppBase, Entity, Vec3 } from 'playcanvas';
2
2
 
3
+ import type { AppElement } from './app';
3
4
  import { AsyncElement } from './async-element';
4
5
  import { parseBool, parseTags, parseVec3 } from './parse';
5
6
 
@@ -76,12 +77,19 @@ class EntityElement extends AsyncElement {
76
77
  private _entity: Entity | null = null;
77
78
 
78
79
  /**
79
- * The PlayCanvas entity instance. Available once the element is ready await
80
- * {@link whenReady} or the element's `ready()` promise before accessing it.
81
- * @returns The entity instance.
80
+ * The application element this entity is registered with, cached at creation time so the
81
+ * entity can be unregistered even once this element has left the DOM.
82
82
  */
83
- get entity(): Entity {
84
- return this._entity!;
83
+ private _appElement: AppElement | null = null;
84
+
85
+ /**
86
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
87
+ * been removed from the document — await {@link whenReady} or the element's `ready()`
88
+ * promise before accessing it.
89
+ * @returns The entity instance, or `null`.
90
+ */
91
+ get entity(): Entity | null {
92
+ return this._entity;
85
93
  }
86
94
 
87
95
  createEntity(app: AppBase) {
@@ -92,19 +100,44 @@ class EntityElement extends AsyncElement {
92
100
  return;
93
101
  }
94
102
 
95
- // Create a new entity
96
- const entity = new Entity(this.getAttribute('name') || this._name, app);
103
+ // Seed from the cached fields rather than re-reading the attributes. Every observed
104
+ // attribute is routed through its property setter by attributeChangedCallback, so the field
105
+ // already holds the parsed attribute value - and it also holds anything assigned through the
106
+ // property API before the app booted, which reading the attribute back would discard.
107
+ const entity = new Entity(this._name, app);
97
108
  this._entity = entity;
98
109
 
99
- entity.enabled = parseBool(this.getAttribute('enabled'), true);
100
- entity.setLocalPosition(parseVec3(this.getAttribute('position'), Vec3.ZERO, 'position'));
101
- entity.setLocalEulerAngles(parseVec3(this.getAttribute('rotation'), Vec3.ZERO, 'rotation'));
102
- entity.setLocalScale(parseVec3(this.getAttribute('scale'), Vec3.ONE, 'scale'));
110
+ entity.enabled = this._enabled;
111
+ entity.setLocalPosition(this._position);
112
+ entity.setLocalEulerAngles(this._rotation);
113
+ entity.setLocalScale(this._scale);
103
114
 
104
- const tags = parseTags(this.getAttribute('tags'));
105
- if (tags.length > 0) {
106
- entity.tags.add(tags);
115
+ if (this._tags.length > 0) {
116
+ entity.tags.add(this._tags);
107
117
  }
118
+
119
+ // Register with the owning application, which joins engine nodes back to elements by
120
+ // identity (never by name), and hook the entity's destruction. The engine fires 'destroy'
121
+ // for every entity in a destroyed subtree, so the element learns of its entity's death no
122
+ // matter who causes it: this element, an ancestor, the whole application, or a user
123
+ // script calling entity.destroy().
124
+ this._appElement = this.closestApp;
125
+ this._appElement?._registerEntityElement(entity, this);
126
+ entity.once('destroy', this._onEntityDestroy, this);
127
+ }
128
+
129
+ /**
130
+ * Handles the destruction of the backing entity. Resets the element so a later re-insertion
131
+ * starts clean: `_built` must be cleared alongside `_entity`, or buildHierarchy would bail
132
+ * and a re-created entity would never be parented.
133
+ *
134
+ * @param entity - The entity that was destroyed.
135
+ */
136
+ private _onEntityDestroy(entity: Entity) {
137
+ this._appElement?._unregisterEntityElement(entity);
138
+ this._appElement = null;
139
+ this._entity = null;
140
+ this._built = false;
108
141
  }
109
142
 
110
143
  buildHierarchy(app: AppBase) {
@@ -124,7 +157,15 @@ class EntityElement extends AsyncElement {
124
157
  connectedCallback() {
125
158
  // Wait for app to be ready
126
159
  const closestApp = this.closestApp;
127
- if (!closestApp) return;
160
+ if (!closestApp) {
161
+ // An entity outside an application is inert and never becomes ready, so awaiting it
162
+ // hangs. Warn rather than fail silently, naming the parent it requires, as every other
163
+ // misplaced element does.
164
+ const name = this.getAttribute('name');
165
+ const label = name ? ` '${name}'` : '';
166
+ console.warn(`pc-entity${label} must be a descendant of pc-app - entity not created`);
167
+ return;
168
+ }
128
169
 
129
170
  // If app is already running, create entity immediately
130
171
  if (closestApp.hierarchyReady) {
@@ -145,18 +186,11 @@ class EntityElement extends AsyncElement {
145
186
  }
146
187
 
147
188
  disconnectedCallback() {
148
- if (this.entity) {
149
- // Notify all children that their entities are about to become invalid
150
- const children = this.querySelectorAll('pc-entity');
151
- children.forEach((child) => {
152
- (child as EntityElement)._entity = null;
153
- });
154
-
155
- // Destroy the entity
156
- this.entity.destroy();
157
- this._entity = null;
158
- this._built = false;
159
- }
189
+ // Destroying the entity destroys its whole subtree, and the engine fires 'destroy' for
190
+ // every entity in it - so _onEntityDestroy resets this element AND every descendant
191
+ // element before the descendants' own disconnectedCallbacks run. Their entities are null
192
+ // by then, making this call a no-op for them.
193
+ this._entity?.destroy();
160
194
  }
161
195
 
162
196
  /**
@@ -0,0 +1,122 @@
1
+ /** Covers the 0.2s opacity transition; jsdom never fires transitionend, so removal is timed. */
2
+ const REMOVAL_DELAY_MS = 250;
3
+
4
+ /**
5
+ * The slim progress bar `<pc-app>` shows while it boots and preloads. An implementation detail of
6
+ * AppElement rather than a custom element, so its shape can change without a breaking change.
7
+ *
8
+ * All styling is inline, so the library injects no stylesheet. The colors and height resolve CSS
9
+ * custom properties — `--pc-loading-bar-color`, `--pc-loading-bar-background` and
10
+ * `--pc-loading-bar-height` — so a page can theme the bar from `pc-app` or `:root`.
11
+ */
12
+ class LoadingBar {
13
+ private _track: HTMLDivElement;
14
+
15
+ private _fill: HTMLDivElement;
16
+
17
+ private _sweep: Animation | null = null;
18
+
19
+ private _removal: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ /**
22
+ * Creates the bar and appends it to `parent`, starting in the indeterminate state.
23
+ * @param parent - The element to append the bar to.
24
+ */
25
+ constructor(parent: HTMLElement) {
26
+ this._track = document.createElement('div');
27
+ this._track.setAttribute('role', 'progressbar');
28
+ this._track.setAttribute('aria-label', 'Loading');
29
+ this._track.setAttribute('aria-valuemin', '0');
30
+ this._track.setAttribute('aria-valuemax', '100');
31
+ // Fixed positioning matches the canvas, which always fills the window (FILLMODE_FILL_WINDOW)
32
+ this._track.style.cssText = [
33
+ 'position: fixed',
34
+ 'top: 0',
35
+ 'left: 0',
36
+ 'width: 100%',
37
+ 'height: var(--pc-loading-bar-height, 3px)',
38
+ 'background: var(--pc-loading-bar-background, rgba(0, 0, 0, 0.1))',
39
+ 'z-index: 10000',
40
+ 'pointer-events: none',
41
+ 'opacity: 1',
42
+ 'transition: opacity 0.2s ease'
43
+ ].join('; ');
44
+
45
+ this._fill = document.createElement('div');
46
+ this._fill.style.cssText = [
47
+ 'width: 100%',
48
+ 'height: 100%',
49
+ 'transform-origin: left center',
50
+ 'transform: scaleX(0)',
51
+ 'background: var(--pc-loading-bar-color, #f60)',
52
+ 'transition: transform 0.2s ease'
53
+ ].join('; ');
54
+
55
+ this._track.appendChild(this._fill);
56
+ parent.appendChild(this._track);
57
+
58
+ // Indeterminate sweep until the first progress() call reports a real total. No
59
+ // aria-valuenow is set, which is what marks a progressbar indeterminate. jsdom has no Web
60
+ // Animations API, so the guard degrades to a static bar there rather than crashing boot.
61
+ if (typeof this._fill.animate === 'function') {
62
+ this._sweep = this._fill.animate([
63
+ { transform: 'scaleX(0.25) translateX(-100%)' },
64
+ { transform: 'scaleX(0.25) translateX(500%)' }
65
+ ], {
66
+ duration: 1000,
67
+ iterations: Infinity,
68
+ easing: 'ease-in-out'
69
+ });
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Reflects preload progress, switching the bar from indeterminate to determinate on the first
75
+ * call.
76
+ * @param loaded - The number of assets that have finished loading.
77
+ * @param total - The number of assets being preloaded.
78
+ */
79
+ progress(loaded: number, total: number) {
80
+ if (this._sweep) {
81
+ this._sweep.cancel();
82
+ this._sweep = null;
83
+ }
84
+ const fraction = total === 0 ? 1 : loaded / total;
85
+ this._track.setAttribute('aria-valuenow', String(Math.round(fraction * 100)));
86
+ this._fill.style.transform = `scaleX(${fraction})`;
87
+ }
88
+
89
+ /**
90
+ * Fills the bar, fades it out and removes it. Idempotent.
91
+ */
92
+ complete() {
93
+ if (this._removal !== null) {
94
+ return;
95
+ }
96
+ if (this._sweep) {
97
+ this._sweep.cancel();
98
+ this._sweep = null;
99
+ }
100
+ this._track.setAttribute('aria-valuenow', '100');
101
+ this._fill.style.transform = 'scaleX(1)';
102
+ this._track.style.opacity = '0';
103
+ this._removal = setTimeout(() => this._track.remove(), REMOVAL_DELAY_MS);
104
+ }
105
+
106
+ /**
107
+ * Removes the bar immediately, cancelling any pending fade. Idempotent.
108
+ */
109
+ destroy() {
110
+ if (this._sweep) {
111
+ this._sweep.cancel();
112
+ this._sweep = null;
113
+ }
114
+ if (this._removal !== null) {
115
+ clearTimeout(this._removal);
116
+ this._removal = null;
117
+ }
118
+ this._track.remove();
119
+ }
120
+ }
121
+
122
+ export { LoadingBar };
package/src/material.ts CHANGED
@@ -114,8 +114,14 @@ type TextureSlot = 'aoMap' | 'diffuseMap' | 'emissiveMap' | 'glossMap' | 'height
114
114
  * created on insertion.
115
115
  *
116
116
  * The element is metal/rough by default: unlike a bare `StandardMaterial` it enables the metalness
117
- * workflow, which is what the `metalness-*` attributes assume and what glTF means by PBR. The
118
- * `roughness` and `roughness-map` attributes are aliases for `gloss` and `gloss-map` that
117
+ * workflow, which is what the `metalness-*` attributes assume and what glTF means by PBR. It also
118
+ * defaults `metalness` to 0 rather than the engine's 1, because those two defaults have to be
119
+ * chosen together - the engine's 1 is unreachable under its own `useMetalness` of false, and with
120
+ * the workflow on it would make every material fully metallic, so `<pc-material diffuse="crimson">`
121
+ * would render as dark tinted reflections of an environment that may not exist rather than as a
122
+ * crimson surface. `metalness="1"` remains one attribute away.
123
+ *
124
+ * The `roughness` and `roughness-map` attributes are aliases for `gloss` and `gloss-map` that
119
125
  * additionally invert the gloss channel; do not mix the two families on one element.
120
126
  *
121
127
  * The two aliases are documented here rather than on an accessor, because they resolve to the
@@ -222,7 +228,7 @@ class MaterialElement extends HTMLElement {
222
228
 
223
229
  private _heightMapUv = 0;
224
230
 
225
- private _metalness = 1;
231
+ private _metalness = 0;
226
232
 
227
233
  private _metalnessMap = '';
228
234
 
@@ -2386,7 +2392,7 @@ class MaterialElement extends HTMLElement {
2386
2392
  this.heightMapUv = parseNumber(newValue, 0, name);
2387
2393
  break;
2388
2394
  case 'metalness':
2389
- this.metalness = parseNumber(newValue, 1, name);
2395
+ this.metalness = parseNumber(newValue, 0, name);
2390
2396
  break;
2391
2397
  case 'metalness-map':
2392
2398
  this.metalnessMap = newValue ?? '';