@materials3d/element 0.4.1 → 0.6.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/README.md CHANGED
@@ -35,13 +35,15 @@ registers the class under another tag name.
35
35
  | `transparent` | drop the backdrop, so the scene composites over the page |
36
36
  | `paused` | live; toggles playback |
37
37
  | `poster`, `poster-fit` | `poster-fit` is `fill` (default, matches the canvas), `cover` or `contain` |
38
+ | `fade-ms` | poster→canvas crossfade in ms; default `300`, `0` swaps instantly |
38
39
  | `lazy`, `webgl`, `min-size` | shell options |
39
40
  | `renderer` | `webgpu` selects the experimental WebGPU/TSL engine; anything else is WebGL |
40
41
 
41
42
  Boolean attributes: present means true, `"false"` or `"0"` means false, absent means the shell
42
43
  default.
43
44
 
44
- `poster`, `poster-fit`, `lazy`, `webgl`, `min-size` and `renderer` are read once at mount.
45
+ `poster`, `poster-fit`, `fade-ms`, `lazy`, `webgl`, `min-size` and `renderer` are read once at
46
+ mount.
45
47
  Changing one on a connected element does nothing until it is disconnected and reconnected.
46
48
  `config`, `src`, `preset`, `transparent` and `paused` are live.
47
49
 
@@ -65,6 +67,31 @@ if (el) {
65
67
  }
66
68
  ```
67
69
 
70
+ ### Capture a poster at the pixel ratio it will be shown at
71
+
72
+ `snapshot()` returns the canvas at its **backing-store** size: the element's CSS size times the
73
+ device pixel ratio, capped by `dprMax`. If posters are generated in a headless browser, that
74
+ browser's `deviceScaleFactor` is what decides their resolution — and it defaults to `1`.
75
+
76
+ A poster captured that way is half the resolution the same canvas renders at on a 2x display. The
77
+ browser stretches it to fill the element while the live canvas behind it does not, so the crossfade
78
+ lands on a visible sharpening: edges tighten, and because soft edges bloom outward the poster reads
79
+ as slightly _heavier_ than the live scene rather than merely blurrier.
80
+
81
+ ```js
82
+ const page = await browser.newPage({
83
+ viewport: { width: 1440, height: 820 },
84
+ deviceScaleFactor: 2,
85
+ });
86
+ ```
87
+
88
+ Two things go with it. Keep the encoder quality high enough not to ring the high-contrast edges — a
89
+ lossy poster shows up as thin outlines tracing every rim. And remember a fixed-aspect poster is
90
+ stretched by `poster-fit` while the canvas re-frames itself from `camera.fit`, so capture at the
91
+ aspect the element is widest at. Where the two policies disagree — `minVisibleWidth` widens the
92
+ camera but never the poster — expect a mismatch at narrow sizes; `fade-ms="0"` at least stops it
93
+ being a dissolve.
94
+
68
95
  Peer dependency: `three >= 0.180 < 1`. `@types/three` is an optional peer.
69
96
 
70
97
  MIT.
package/dist/index.d.ts CHANGED
@@ -10,11 +10,11 @@ declare const ElementBase: typeof HTMLElement;
10
10
  * playback. These are LIVE: a change after mount is pushed to the running scene. A `src` is
11
11
  * fetched once per URL and a `preset` resolved once per name, so an update re-reads neither.
12
12
  *
13
- * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `lazy`, `webgl`
14
- * (`auto` | `force` | `off`), `min-size` (CSS px) and `renderer` (`webgl`, the default, or
15
- * `webgpu` for the experimental node engine, a separate bundle). Read once at mount; changing one
16
- * on a live element does nothing until it is re-connected (the same contract as the React
17
- * wrapper's mount-time props).
13
+ * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `fade-ms` (poster→canvas
14
+ * crossfade, default 300; `0` swaps instantly), `lazy`, `webgl` (`auto` | `force` | `off`),
15
+ * `min-size` (CSS px) and `renderer` (`webgl`, the default, or `webgpu` for the experimental node
16
+ * engine, a separate bundle). Read once at mount; changing one on a live element does nothing
17
+ * until it is re-connected (the same contract as the React wrapper's mount-time props).
18
18
  *
19
19
  * Also a `config` property and a read-only `handle` getter. Emits `materials3d-ready` (detail =
20
20
  * renderer) and `materials3d-fallback` (detail = reason).
package/dist/index.js CHANGED
@@ -18,11 +18,11 @@ const ElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : function
18
18
  * playback. These are LIVE: a change after mount is pushed to the running scene. A `src` is
19
19
  * fetched once per URL and a `preset` resolved once per name, so an update re-reads neither.
20
20
  *
21
- * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `lazy`, `webgl`
22
- * (`auto` | `force` | `off`), `min-size` (CSS px) and `renderer` (`webgl`, the default, or
23
- * `webgpu` for the experimental node engine, a separate bundle). Read once at mount; changing one
24
- * on a live element does nothing until it is re-connected (the same contract as the React
25
- * wrapper's mount-time props).
21
+ * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `fade-ms` (poster→canvas
22
+ * crossfade, default 300; `0` swaps instantly), `lazy`, `webgl` (`auto` | `force` | `off`),
23
+ * `min-size` (CSS px) and `renderer` (`webgl`, the default, or `webgpu` for the experimental node
24
+ * engine, a separate bundle). Read once at mount; changing one on a live element does nothing
25
+ * until it is re-connected (the same contract as the React wrapper's mount-time props).
26
26
  *
27
27
  * Also a `config` property and a read-only `handle` getter. Emits `materials3d-ready` (detail =
28
28
  * renderer) and `materials3d-fallback` (detail = reason).
@@ -98,6 +98,7 @@ var Materials3DElement = class extends ElementBase {
98
98
  const options = {
99
99
  poster: this.getAttribute("poster") ?? void 0,
100
100
  posterFit: this.getAttribute("poster-fit") ?? void 0,
101
+ fadeMs: this.#numAttr("fade-ms"),
101
102
  lazy: this.#boolAttr("lazy"),
102
103
  webgl: this.getAttribute("webgl") ?? void 0,
103
104
  minSizeForWebGL: Number.isFinite(minSize) && minSize > 0 ? minSize : void 0,
@@ -154,6 +155,19 @@ var Materials3DElement = class extends ElementBase {
154
155
  if (this.#handle !== handle) return;
155
156
  handle.set(config);
156
157
  }
158
+ /**
159
+ * A non-negative number attribute, or undefined for the shell default.
160
+ *
161
+ * Not the inline `Number(getAttribute(...))` that `min-size` uses: that leans on `Number(null)`
162
+ * being 0 and rejects 0 as "absent", which is fine for a size and wrong for a duration.
163
+ * `fade-ms="0"` means swap with no crossfade at all, so it must not share an encoding with
164
+ * "not set". Unparseable and negative still fall through to the default.
165
+ */
166
+ #numAttr(name) {
167
+ if (!this.hasAttribute(name)) return void 0;
168
+ const value = Number(this.getAttribute(name));
169
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
170
+ }
157
171
  /** Presence = true; `"false"`/`"0"` = false; absent = undefined (shell default). */
158
172
  #boolAttr(name) {
159
173
  if (!this.hasAttribute(name)) return void 0;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#handle","#config","#scheduleUpdate","#mount","#mountId","#debounce","#boolAttr","#buildConfig","#preset","#src","#update"],"sources":["../src/index.ts"],"sourcesContent":["import { createMaterials, mergeSceneConfig } from \"@materials3d/core\";\nimport type {\n EngineFor,\n RendererKind,\n FallbackReason,\n SceneConfig,\n MaterialHandle,\n MaterialOptions,\n TiltStatus,\n} from \"@materials3d/core\";\n\n/** The attributes a live element answers to. The shell options are read once at mount instead. */\nconst LIVE_ATTRIBUTES = [\"config\", \"src\", \"preset\", \"transparent\", \"paused\"] as const;\n\n// SSR-safe base: `class extends HTMLElement` evaluates HTMLElement at import time, which throws\n// under Node. Fall back to a dummy base there, the element is never instantiated server-side\n// (register() is guarded), so the missing DOM methods are never called.\nconst ElementBase: typeof HTMLElement =\n typeof HTMLElement !== \"undefined\"\n ? HTMLElement\n : (function Materials3DElementBase() {} as unknown as typeof HTMLElement);\n\n/**\n * `<materials-3d>`: the framework-agnostic drop-in (Vue/Svelte/plain HTML). Light DOM, `display:block`.\n *\n * Config attributes: `config` (JSON), `src` (URL to a config JSON), `preset` (name) and\n * `transparent` (drop the backdrop; `\"false\"` or `\"0\"` keeps it), merged in that order, each one\n * level deep (see `mergeSceneConfig`), then the `config` property last. `paused` starts and stops\n * playback. These are LIVE: a change after mount is pushed to the running scene. A `src` is\n * fetched once per URL and a `preset` resolved once per name, so an update re-reads neither.\n *\n * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `lazy`, `webgl`\n * (`auto` | `force` | `off`), `min-size` (CSS px) and `renderer` (`webgl`, the default, or\n * `webgpu` for the experimental node engine, a separate bundle). Read once at mount; changing one\n * on a live element does nothing until it is re-connected (the same contract as the React\n * wrapper's mount-time props).\n *\n * Also a `config` property and a read-only `handle` getter. Emits `materials3d-ready` (detail =\n * renderer) and `materials3d-fallback` (detail = reason).\n */\nexport class Materials3DElement extends ElementBase {\n static get observedAttributes(): string[] {\n return [...LIVE_ATTRIBUTES];\n }\n\n #handle: MaterialHandle | null = null;\n #config: Partial<SceneConfig> = {};\n #debounce?: ReturnType<typeof setTimeout>;\n /**\n * Which connect the in-flight `#mount` belongs to. `#mount` awaits the config, and a\n * disconnect + reconnect during that await queues a SECOND mount, without this check both\n * would finish and the first handle would be overwritten undestroyed (a leaked renderer).\n */\n #mountId = 0;\n /** The preset the last build resolved, so an update to another attribute skips the import. */\n #preset: { name: string; make: () => Partial<SceneConfig> } | null = null;\n /** The last `src` fetched successfully; a failed fetch is not cached, so the next update retries. */\n #src: { url: string; config: Partial<SceneConfig> } | null = null;\n\n /** The live shell handle (null before connect / after disconnect). */\n get handle(): MaterialHandle | null {\n return this.#handle;\n }\n\n /**\n * Explicitly ask for the device-orientation sensor. OPTIONAL, and on iOS it opens a modal\n * permission dialog — nothing calls it for you, and a decorative scene should simply go without\n * tilt there. CALL IT FROM A USER GESTURE (iOS grants the sensor only from inside a tap handler)\n * and only once `materials3d-ready` has fired — before that there is no renderer to ask, and the\n * replayed request has left the gesture.\n */\n enableTilt(): Promise<boolean> {\n return this.#handle?.enableTilt() ?? Promise.resolve(false);\n }\n\n /** Where the tilt sensor stands. `\"prompt\"` is exactly when a tap-to-enable affordance helps. */\n tiltStatus(): TiltStatus {\n return this.#handle?.tiltStatus() ?? \"prompt\";\n }\n\n /** Take the next orientation reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void {\n this.#handle?.recenterTilt();\n }\n\n /** Programmatic config, merged last (over the `preset`/`src`/`config` attributes). */\n get config(): Partial<SceneConfig> {\n return this.#config;\n }\n set config(value: Partial<SceneConfig>) {\n this.#config = value ?? {};\n this.#scheduleUpdate();\n }\n\n connectedCallback(): void {\n if (!this.style.display) this.style.display = \"block\";\n void this.#mount(++this.#mountId);\n }\n\n disconnectedCallback(): void {\n this.#mountId++; // invalidate a mount still awaiting its config\n clearTimeout(this.#debounce);\n this.#handle?.destroy();\n this.#handle = null;\n }\n\n attributeChangedCallback(name: string): void {\n if (!this.#handle) return;\n if (name === \"paused\") {\n if (this.#boolAttr(\"paused\")) this.#handle.pause();\n else this.#handle.play();\n } else {\n this.#scheduleUpdate();\n }\n }\n\n async #mount(id: number): Promise<void> {\n const config = await this.#buildConfig();\n if (id !== this.#mountId || !this.isConnected) return; // superseded while the config resolved\n const minSize = Number(this.getAttribute(\"min-size\"));\n const options: MaterialOptions<RendererKind> = {\n poster: this.getAttribute(\"poster\") ?? undefined,\n posterFit: (this.getAttribute(\"poster-fit\") as MaterialOptions[\"posterFit\"]) ?? undefined,\n lazy: this.#boolAttr(\"lazy\"),\n webgl: (this.getAttribute(\"webgl\") as MaterialOptions[\"webgl\"]) ?? undefined,\n minSizeForWebGL: Number.isFinite(minSize) && minSize > 0 ? minSize : undefined,\n paused: this.#boolAttr(\"paused\"),\n // `renderer=\"webgpu\"` fetches the node-renderer build. Anything else, including absent, is\n // the default WebGL engine, the option is opt-in precisely because it is a second bundle.\n renderer: this.getAttribute(\"renderer\") === \"webgpu\" ? \"webgpu\" : undefined,\n onReady: (renderer: EngineFor<RendererKind>) =>\n this.dispatchEvent(new CustomEvent(\"materials3d-ready\", { detail: renderer })),\n onFallback: (reason: FallbackReason) =>\n this.dispatchEvent(new CustomEvent(\"materials3d-fallback\", { detail: reason })),\n };\n this.#handle = createMaterials<RendererKind>(this, config, options);\n }\n\n /** default ← preset ← src JSON ← config attribute ← config property, one level deep each. */\n async #buildConfig(): Promise<Partial<SceneConfig>> {\n let base: Partial<SceneConfig> = {};\n const presetName = this.getAttribute(\"preset\");\n if (presetName) {\n if (this.#preset?.name !== presetName) {\n const { PRESETS, isPresetName } = await import(\"@materials3d/core/presets\");\n const make = isPresetName(presetName)\n ? PRESETS[presetName]\n : (): Partial<SceneConfig> => ({});\n this.#preset = { name: presetName, make };\n }\n base = this.#preset.make();\n }\n const src = this.getAttribute(\"src\");\n if (src) {\n if (this.#src?.url !== src) {\n try {\n const json: unknown = await fetch(src).then((r) => r.json());\n if (json && typeof json === \"object\" && !Array.isArray(json)) {\n this.#src = { url: src, config: json as Partial<SceneConfig> };\n }\n } catch {\n // ignore a failed/invalid config fetch; fall through to whatever we have\n }\n }\n // Cloned so the cache never shares an object with a config the shell may go on to hold.\n if (this.#src?.url === src) base = mergeSceneConfig(base, structuredClone(this.#src.config));\n }\n const transparent = this.#boolAttr(\"transparent\");\n if (transparent !== undefined) base = { ...base, transparentBackground: transparent };\n return mergeSceneConfig(\n mergeSceneConfig(base, parseJson(this.getAttribute(\"config\"))),\n this.#config,\n );\n }\n\n #scheduleUpdate(): void {\n clearTimeout(this.#debounce);\n this.#debounce = setTimeout(() => {\n void this.#update();\n }, 50);\n }\n\n async #update(): Promise<void> {\n const handle = this.#handle;\n if (!handle) return;\n const config = await this.#buildConfig();\n if (this.#handle !== handle) return; // destroyed or remounted while the config resolved\n handle.set(config);\n }\n\n /** Presence = true; `\"false\"`/`\"0\"` = false; absent = undefined (shell default). */\n #boolAttr(name: string): boolean | undefined {\n if (!this.hasAttribute(name)) return undefined;\n const value = this.getAttribute(name);\n return value !== \"false\" && value !== \"0\";\n }\n}\n\nfunction parseJson(json: string | null): Partial<SceneConfig> {\n if (!json) return {};\n try {\n return JSON.parse(json) as Partial<SceneConfig>;\n } catch {\n return {};\n }\n}\n\n/** Define the element (idempotent, SSR/Node-import-safe). */\nexport function register(tag = \"materials-3d\"): void {\n if (typeof window === \"undefined\" || typeof customElements === \"undefined\") return;\n if (!customElements.get(tag)) customElements.define(tag, Materials3DElement);\n}\n\n// Self-register on import so a bare `import \"@materials3d/element\"` makes <materials-3d> work. Guarded so\n// importing under Node (SSR) is a no-op rather than a ReferenceError.\nregister();\n\ndeclare global {\n interface HTMLElementTagNameMap {\n /** So `document.querySelector(\"materials-3d\")` types as the element. */\n \"materials-3d\": Materials3DElement;\n }\n}\n\nexport type {\n SceneConfig,\n MaterialHandle,\n FallbackReason,\n SnapshotOptions,\n} from \"@materials3d/core\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["#handle","#config","#scheduleUpdate","#mount","#mountId","#debounce","#boolAttr","#buildConfig","#numAttr","#preset","#src","#update"],"sources":["../src/index.ts"],"sourcesContent":["import { createMaterials, mergeSceneConfig } from \"@materials3d/core\";\nimport type {\n EngineFor,\n RendererKind,\n FallbackReason,\n SceneConfig,\n MaterialHandle,\n MaterialOptions,\n TiltStatus,\n} from \"@materials3d/core\";\n\n/** The attributes a live element answers to. The shell options are read once at mount instead. */\nconst LIVE_ATTRIBUTES = [\"config\", \"src\", \"preset\", \"transparent\", \"paused\"] as const;\n\n// SSR-safe base: `class extends HTMLElement` evaluates HTMLElement at import time, which throws\n// under Node. Fall back to a dummy base there, the element is never instantiated server-side\n// (register() is guarded), so the missing DOM methods are never called.\nconst ElementBase: typeof HTMLElement =\n typeof HTMLElement !== \"undefined\"\n ? HTMLElement\n : (function Materials3DElementBase() {} as unknown as typeof HTMLElement);\n\n/**\n * `<materials-3d>`: the framework-agnostic drop-in (Vue/Svelte/plain HTML). Light DOM, `display:block`.\n *\n * Config attributes: `config` (JSON), `src` (URL to a config JSON), `preset` (name) and\n * `transparent` (drop the backdrop; `\"false\"` or `\"0\"` keeps it), merged in that order, each one\n * level deep (see `mergeSceneConfig`), then the `config` property last. `paused` starts and stops\n * playback. These are LIVE: a change after mount is pushed to the running scene. A `src` is\n * fetched once per URL and a `preset` resolved once per name, so an update re-reads neither.\n *\n * Shell options: `poster`, `poster-fit` (`fill` | `cover` | `contain`), `fade-ms` (poster→canvas\n * crossfade, default 300; `0` swaps instantly), `lazy`, `webgl` (`auto` | `force` | `off`),\n * `min-size` (CSS px) and `renderer` (`webgl`, the default, or `webgpu` for the experimental node\n * engine, a separate bundle). Read once at mount; changing one on a live element does nothing\n * until it is re-connected (the same contract as the React wrapper's mount-time props).\n *\n * Also a `config` property and a read-only `handle` getter. Emits `materials3d-ready` (detail =\n * renderer) and `materials3d-fallback` (detail = reason).\n */\nexport class Materials3DElement extends ElementBase {\n static get observedAttributes(): string[] {\n return [...LIVE_ATTRIBUTES];\n }\n\n #handle: MaterialHandle | null = null;\n #config: Partial<SceneConfig> = {};\n #debounce?: ReturnType<typeof setTimeout>;\n /**\n * Which connect the in-flight `#mount` belongs to. `#mount` awaits the config, and a\n * disconnect + reconnect during that await queues a SECOND mount, without this check both\n * would finish and the first handle would be overwritten undestroyed (a leaked renderer).\n */\n #mountId = 0;\n /** The preset the last build resolved, so an update to another attribute skips the import. */\n #preset: { name: string; make: () => Partial<SceneConfig> } | null = null;\n /** The last `src` fetched successfully; a failed fetch is not cached, so the next update retries. */\n #src: { url: string; config: Partial<SceneConfig> } | null = null;\n\n /** The live shell handle (null before connect / after disconnect). */\n get handle(): MaterialHandle | null {\n return this.#handle;\n }\n\n /**\n * Explicitly ask for the device-orientation sensor. OPTIONAL, and on iOS it opens a modal\n * permission dialog — nothing calls it for you, and a decorative scene should simply go without\n * tilt there. CALL IT FROM A USER GESTURE (iOS grants the sensor only from inside a tap handler)\n * and only once `materials3d-ready` has fired — before that there is no renderer to ask, and the\n * replayed request has left the gesture.\n */\n enableTilt(): Promise<boolean> {\n return this.#handle?.enableTilt() ?? Promise.resolve(false);\n }\n\n /** Where the tilt sensor stands. `\"prompt\"` is exactly when a tap-to-enable affordance helps. */\n tiltStatus(): TiltStatus {\n return this.#handle?.tiltStatus() ?? \"prompt\";\n }\n\n /** Take the next orientation reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void {\n this.#handle?.recenterTilt();\n }\n\n /** Programmatic config, merged last (over the `preset`/`src`/`config` attributes). */\n get config(): Partial<SceneConfig> {\n return this.#config;\n }\n set config(value: Partial<SceneConfig>) {\n this.#config = value ?? {};\n this.#scheduleUpdate();\n }\n\n connectedCallback(): void {\n if (!this.style.display) this.style.display = \"block\";\n void this.#mount(++this.#mountId);\n }\n\n disconnectedCallback(): void {\n this.#mountId++; // invalidate a mount still awaiting its config\n clearTimeout(this.#debounce);\n this.#handle?.destroy();\n this.#handle = null;\n }\n\n attributeChangedCallback(name: string): void {\n if (!this.#handle) return;\n if (name === \"paused\") {\n if (this.#boolAttr(\"paused\")) this.#handle.pause();\n else this.#handle.play();\n } else {\n this.#scheduleUpdate();\n }\n }\n\n async #mount(id: number): Promise<void> {\n const config = await this.#buildConfig();\n if (id !== this.#mountId || !this.isConnected) return; // superseded while the config resolved\n const minSize = Number(this.getAttribute(\"min-size\"));\n const options: MaterialOptions<RendererKind> = {\n poster: this.getAttribute(\"poster\") ?? undefined,\n posterFit: (this.getAttribute(\"poster-fit\") as MaterialOptions[\"posterFit\"]) ?? undefined,\n fadeMs: this.#numAttr(\"fade-ms\"),\n lazy: this.#boolAttr(\"lazy\"),\n webgl: (this.getAttribute(\"webgl\") as MaterialOptions[\"webgl\"]) ?? undefined,\n minSizeForWebGL: Number.isFinite(minSize) && minSize > 0 ? minSize : undefined,\n paused: this.#boolAttr(\"paused\"),\n // `renderer=\"webgpu\"` fetches the node-renderer build. Anything else, including absent, is\n // the default WebGL engine, the option is opt-in precisely because it is a second bundle.\n renderer: this.getAttribute(\"renderer\") === \"webgpu\" ? \"webgpu\" : undefined,\n onReady: (renderer: EngineFor<RendererKind>) =>\n this.dispatchEvent(new CustomEvent(\"materials3d-ready\", { detail: renderer })),\n onFallback: (reason: FallbackReason) =>\n this.dispatchEvent(new CustomEvent(\"materials3d-fallback\", { detail: reason })),\n };\n this.#handle = createMaterials<RendererKind>(this, config, options);\n }\n\n /** default ← preset ← src JSON ← config attribute ← config property, one level deep each. */\n async #buildConfig(): Promise<Partial<SceneConfig>> {\n let base: Partial<SceneConfig> = {};\n const presetName = this.getAttribute(\"preset\");\n if (presetName) {\n if (this.#preset?.name !== presetName) {\n const { PRESETS, isPresetName } = await import(\"@materials3d/core/presets\");\n const make = isPresetName(presetName)\n ? PRESETS[presetName]\n : (): Partial<SceneConfig> => ({});\n this.#preset = { name: presetName, make };\n }\n base = this.#preset.make();\n }\n const src = this.getAttribute(\"src\");\n if (src) {\n if (this.#src?.url !== src) {\n try {\n const json: unknown = await fetch(src).then((r) => r.json());\n if (json && typeof json === \"object\" && !Array.isArray(json)) {\n this.#src = { url: src, config: json as Partial<SceneConfig> };\n }\n } catch {\n // ignore a failed/invalid config fetch; fall through to whatever we have\n }\n }\n // Cloned so the cache never shares an object with a config the shell may go on to hold.\n if (this.#src?.url === src) base = mergeSceneConfig(base, structuredClone(this.#src.config));\n }\n const transparent = this.#boolAttr(\"transparent\");\n if (transparent !== undefined) base = { ...base, transparentBackground: transparent };\n return mergeSceneConfig(\n mergeSceneConfig(base, parseJson(this.getAttribute(\"config\"))),\n this.#config,\n );\n }\n\n #scheduleUpdate(): void {\n clearTimeout(this.#debounce);\n this.#debounce = setTimeout(() => {\n void this.#update();\n }, 50);\n }\n\n async #update(): Promise<void> {\n const handle = this.#handle;\n if (!handle) return;\n const config = await this.#buildConfig();\n if (this.#handle !== handle) return; // destroyed or remounted while the config resolved\n handle.set(config);\n }\n\n /**\n * A non-negative number attribute, or undefined for the shell default.\n *\n * Not the inline `Number(getAttribute(...))` that `min-size` uses: that leans on `Number(null)`\n * being 0 and rejects 0 as \"absent\", which is fine for a size and wrong for a duration.\n * `fade-ms=\"0\"` means swap with no crossfade at all, so it must not share an encoding with\n * \"not set\". Unparseable and negative still fall through to the default.\n */\n #numAttr(name: string): number | undefined {\n if (!this.hasAttribute(name)) return undefined;\n const value = Number(this.getAttribute(name));\n return Number.isFinite(value) && value >= 0 ? value : undefined;\n }\n\n /** Presence = true; `\"false\"`/`\"0\"` = false; absent = undefined (shell default). */\n #boolAttr(name: string): boolean | undefined {\n if (!this.hasAttribute(name)) return undefined;\n const value = this.getAttribute(name);\n return value !== \"false\" && value !== \"0\";\n }\n}\n\nfunction parseJson(json: string | null): Partial<SceneConfig> {\n if (!json) return {};\n try {\n return JSON.parse(json) as Partial<SceneConfig>;\n } catch {\n return {};\n }\n}\n\n/** Define the element (idempotent, SSR/Node-import-safe). */\nexport function register(tag = \"materials-3d\"): void {\n if (typeof window === \"undefined\" || typeof customElements === \"undefined\") return;\n if (!customElements.get(tag)) customElements.define(tag, Materials3DElement);\n}\n\n// Self-register on import so a bare `import \"@materials3d/element\"` makes <materials-3d> work. Guarded so\n// importing under Node (SSR) is a no-op rather than a ReferenceError.\nregister();\n\ndeclare global {\n interface HTMLElementTagNameMap {\n /** So `document.querySelector(\"materials-3d\")` types as the element. */\n \"materials-3d\": Materials3DElement;\n }\n}\n\nexport type {\n SceneConfig,\n MaterialHandle,\n FallbackReason,\n SnapshotOptions,\n} from \"@materials3d/core\";\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materials3d/element",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Drop-in Materials3D scene for Vue, Svelte, or plain HTML: the <materials-3d> custom element",
5
5
  "keywords": [
6
6
  "custom-elements",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@materials3d/core": "^0.4.1"
42
+ "@materials3d/core": "^0.6.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@types/three": ">=0.180",