@materials3d/react 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amir Abushanab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ <a href="https://github.com/Amir-Abushanab/materials3d"><img src="https://raw.githubusercontent.com/Amir-Abushanab/materials3d/main/brand/icon-192.png" alt="" width="64" align="right"></a>
2
+
3
+ # @materials3d/react
4
+
5
+ The `<Materials3D>` component: a drop-in Materials3D scene for React.
6
+
7
+ ```bash
8
+ pnpm add @materials3d/react three
9
+ ```
10
+
11
+ ```tsx
12
+ import { Materials3D } from "@materials3d/react";
13
+
14
+ export function Hero() {
15
+ return (
16
+ <Materials3D
17
+ preset="skewer"
18
+ poster="/hero.webp"
19
+ minSizeForWebGL={520}
20
+ style={{ width: "100%", height: "100vh" }}
21
+ />
22
+ );
23
+ }
24
+ ```
25
+
26
+ Renders a `<div>` and mounts the shell on the client: poster first, lazy, aware of WebGL, reduced
27
+ motion and Save-Data, with three code-split out of the initial bundle.
28
+
29
+ ## Props
30
+
31
+ Precedence: the defaults, then `preset`, then the flat props, then `config`.
32
+
33
+ | prop | notes |
34
+ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `preset` | a name (`"skewer"`, `"assembly"`, `"staircase"`, `"slimes"`, `"reactions"`, `"materials"`, `"prism"`, `"orb"`) or a function returning a config. A name lazy-loads the presets chunk; a function is tree-shakeable. |
36
+ | `config` | a full or partial config, applied last |
37
+ | `lamps`, `lampGain`, `background`, `transparentBackground`, `clearGlass` | the light field and the backdrop |
38
+ | `post`, `motion`, `scatter` | merged onto what the preset authored, so `scatter={{ count: 24 }}` re-scatters the reference scene; `motion` applies to every shape |
39
+ | `orbit`, `quality`, `dprMax`, `paused` | live |
40
+ | `poster`, `posterFit`, `lazy`, `webgl`, `minSizeForWebGL`, `respectReducedMotion`, `renderer` | shell options, read at mount only. Changing one on a mounted component does nothing until it remounts. |
41
+ | `onReady(renderer)`, `onFallback(reason)` | callbacks; the latest ones are called without a remount |
42
+ | `className`, `style`, `children` | the `<div>` |
43
+
44
+ Config-shaped props update the live scene through the handle. A changed shape list or scatter
45
+ rebuilds geometry; everything else pushes uniforms.
46
+
47
+ ## The handle
48
+
49
+ A `ref` receives a `MaterialHandle`: `snapshot({ time })`, `set(partial)`, `play()`, `pause()`,
50
+ `destroy()`, `state` and `renderer`. The object is stable and delegates to whichever shell is live,
51
+ so it survives StrictMode's remount.
52
+
53
+ ```tsx
54
+ import { useRef } from "react";
55
+ import { Materials3D, type MaterialHandle } from "@materials3d/react";
56
+
57
+ export function Hero() {
58
+ const ref = useRef<MaterialHandle>(null);
59
+ return (
60
+ <>
61
+ <Materials3D ref={ref} preset="orb" poster="/orb.webp" style={{ height: 480 }} />
62
+ <button onClick={() => ref.current?.pause()}>Pause</button>
63
+ </>
64
+ );
65
+ }
66
+ ```
67
+
68
+ Before the shell exists `state` reads `"poster"` and the calls are no-ops. `snapshot` resolves
69
+ `null` until the scene is running; wait for `onReady`.
70
+
71
+ ## SSR
72
+
73
+ The component is SSR-safe. For a server-rendered poster with no hydration flash, pass the image as
74
+ a child and the shell adopts it. The adopted image is put back on unmount, so it survives
75
+ StrictMode's double mount:
76
+
77
+ ```tsx
78
+ <Materials3D preset="skewer">
79
+ <img data-materials3d-poster src="/hero.webp" alt="" />
80
+ </Materials3D>
81
+ ```
82
+
83
+ Peer dependencies: `react >= 18`, `three >= 0.180 < 1`. `@types/three` is an optional peer.
84
+
85
+ MIT.
@@ -0,0 +1,70 @@
1
+ import { CSSProperties, ReactElement, ReactNode, Ref } from "react";
2
+ import { EngineFor, FallbackReason, FallbackReason as FallbackReason$1, LampConfig, MaterialHandle, MaterialHandle as MaterialHandle$1, MotionConfig, PostConfig, PosterFit, RendererKind, ScatterConfig, SceneConfig, SceneConfig as SceneConfig$1, SnapshotOptions } from "@materials3d/core";
3
+ //#region src/index.d.ts
4
+ /** Flat props for the settings people actually reach for; anything else goes through `config`. */
5
+ interface FlatProps {
6
+ /** The bounded field of light behind the glass. */
7
+ lamps?: LampConfig[];
8
+ lampGain?: number;
9
+ background?: string;
10
+ /** Drop the backdrop so the scene composites over the page behind it. */
11
+ transparentBackground?: boolean;
12
+ clearGlass?: string;
13
+ post?: Partial<PostConfig>;
14
+ /** Motion for the shapes a `scatter` generates. Per-shape motion on hand-authored `items` goes
15
+ * through the `config` prop, a flat prop can only mean "all of them". */
16
+ motion?: Partial<MotionConfig>;
17
+ scatter?: Partial<ScatterConfig>;
18
+ orbit?: boolean;
19
+ quality?: number;
20
+ dprMax?: number;
21
+ paused?: boolean;
22
+ }
23
+ interface Materials3DProps<R extends RendererKind = "webgl"> extends FlatProps {
24
+ /** A preset: a function (tree-shakeable) or a name string (lazy-imports the presets chunk). */
25
+ preset?: string | (() => Partial<SceneConfig$1>);
26
+ /**
27
+ * Escape hatch: a full/partial config, applied last and merged one level deep, so
28
+ * `config={{ post: { bloom: 1 } }}` keeps the rest of the post block.
29
+ * Precedence: default ← preset ← flat props ← config.
30
+ */
31
+ config?: Partial<SceneConfig$1>;
32
+ poster?: string;
33
+ /** Poster `object-fit`. Default `"fill"` (matches the canvas → seamless handoff). */
34
+ posterFit?: PosterFit;
35
+ lazy?: boolean;
36
+ webgl?: "auto" | "force" | "off";
37
+ /**
38
+ * Which engine build to fetch. Default `"webgl"`.
39
+ *
40
+ * `"webgpu"` fetches a separate, larger bundle, three's node renderer and TSL, and is the only
41
+ * way to reach a WebGPU backend. It selects the ENGINE, not the backend: it still runs on WebGL
42
+ * where the browser has no WebGPU. See `MaterialOptions.renderer`.
43
+ */
44
+ renderer?: R;
45
+ respectReducedMotion?: boolean;
46
+ /** Stay a poster below this container size in CSS pixels. Four passes at phone DPR is real
47
+ * work, and a still frame of glass loses less than a still frame of most things. */
48
+ minSizeForWebGL?: number;
49
+ className?: string;
50
+ style?: CSSProperties;
51
+ /** Custom SSR poster markup, e.g. `<img data-materials3d-poster src="…" />`, the shell adopts it. */
52
+ children?: ReactNode;
53
+ onReady?: (renderer: EngineFor<R>) => void;
54
+ onFallback?: (reason: FallbackReason$1) => void;
55
+ }
56
+ /**
57
+ * A drop-in, self-optimizing Materials3D scene. Renders a `<div>` (SSR-safe; pass an
58
+ * `<img data-materials3d-poster>` child for a server-rendered poster) and, on the client, mounts the
59
+ * shell: poster-first, lazy, WebGL/reduced-motion/save-data aware, with the engine code-split out.
60
+ *
61
+ * A `ref` receives the {@link MaterialHandle} (`state`, `renderer`, `snapshot()`, `set()`,
62
+ * `play()`, `pause()`), on React 18 and 19 alike. `forwardRef` keeps the generic engine parameter
63
+ * only through this cast, which is why the component is declared this way.
64
+ */
65
+ declare const Materials3D: <R extends RendererKind = "webgl">(props: Materials3DProps<R> & {
66
+ ref?: Ref<MaterialHandle$1<R>>;
67
+ }) => ReactElement;
68
+ //#endregion
69
+ export { type FallbackReason, type MaterialHandle, Materials3D, Materials3D as default, Materials3DProps, type SceneConfig, type SnapshotOptions };
70
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ "use client";
2
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
3
+ import { createDefaultConfig, createMaterials, mergeSceneConfig } from "@materials3d/core";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/index.tsx
6
+ /** Resolve the base config: function preset (sync) → its config; string preset → lazy-load. */
7
+ async function resolveBase(preset) {
8
+ if (typeof preset === "function") return {
9
+ ...createDefaultConfig(),
10
+ ...preset()
11
+ };
12
+ if (typeof preset === "string") {
13
+ const { PRESETS, isPresetName } = await import("@materials3d/core/presets");
14
+ if (isPresetName(preset)) return PRESETS[preset]();
15
+ }
16
+ return createDefaultConfig();
17
+ }
18
+ /** The base we can build synchronously (function preset / default); a string preset resolves later. */
19
+ function syncBase(preset) {
20
+ return typeof preset === "function" ? {
21
+ ...createDefaultConfig(),
22
+ ...preset()
23
+ } : createDefaultConfig();
24
+ }
25
+ /** Apply the flat props (and the config escape hatch) onto a full base config. */
26
+ function buildConfig(base, props) {
27
+ const out = structuredClone(base);
28
+ if (props.lamps !== void 0) out.lamps = props.lamps;
29
+ if (props.lampGain !== void 0) out.lampGain = props.lampGain;
30
+ if (props.background !== void 0) out.background = props.background;
31
+ if (props.transparentBackground !== void 0) out.transparentBackground = props.transparentBackground;
32
+ if (props.clearGlass !== void 0) out.clearGlass = props.clearGlass;
33
+ if (props.post !== void 0) out.post = {
34
+ ...out.post,
35
+ ...props.post
36
+ };
37
+ if (props.motion !== void 0) {
38
+ const motion = props.motion;
39
+ if (out.scatter) out.scatter.motion = {
40
+ ...out.scatter.motion,
41
+ ...motion
42
+ };
43
+ for (const item of out.items) item.motion = {
44
+ ...item.motion,
45
+ ...motion
46
+ };
47
+ }
48
+ if (props.scatter !== void 0 && out.scatter) out.scatter = {
49
+ ...out.scatter,
50
+ ...props.scatter
51
+ };
52
+ if (props.orbit !== void 0) out.orbit = props.orbit;
53
+ if (props.quality !== void 0) out.quality = props.quality;
54
+ if (props.dprMax !== void 0) out.dprMax = props.dprMax;
55
+ if (props.paused !== void 0) out.paused = props.paused;
56
+ return props.config ? mergeSceneConfig(out, props.config) : out;
57
+ }
58
+ /**
59
+ * Function presets are keyed by identity: swapping one function for another must re-run the update
60
+ * effect, and a single string cannot say which function it saw. An inline (per-render) function
61
+ * gets a fresh id each render, which re-runs the effect, `setConfig`'s structural diff makes that
62
+ * a no-op, but memoize the preset if you want the effect to skip entirely.
63
+ */
64
+ const presetIds = /* @__PURE__ */ new WeakMap();
65
+ let nextPresetId = 0;
66
+ function presetKey(preset) {
67
+ if (typeof preset !== "function") return preset;
68
+ let id = presetIds.get(preset);
69
+ if (id === void 0) presetIds.set(preset, id = nextPresetId++);
70
+ return `fn#${id}`;
71
+ }
72
+ /** A stable string that changes whenever the resolved config would, keys the update effect. */
73
+ function configKey(props) {
74
+ const flat = {};
75
+ for (const k of [
76
+ "lamps",
77
+ "lampGain",
78
+ "background",
79
+ "transparentBackground",
80
+ "clearGlass",
81
+ "post",
82
+ "motion",
83
+ "scatter",
84
+ "orbit",
85
+ "quality",
86
+ "dprMax",
87
+ "paused"
88
+ ]) if (props[k] !== void 0) flat[k] = props[k];
89
+ return JSON.stringify({
90
+ preset: presetKey(props.preset),
91
+ flat,
92
+ config: props.config
93
+ });
94
+ }
95
+ /** The state the handle reports before the shell exists: what the server-rendered div shows. */
96
+ const IDLE_STATE = "poster";
97
+ function Materials3DInner(props, ref) {
98
+ const { className, style, children } = props;
99
+ const containerRef = useRef(null);
100
+ const handleRef = useRef(null);
101
+ const cbRef = useRef({});
102
+ cbRef.current.onReady = props.onReady;
103
+ cbRef.current.onFallback = props.onFallback;
104
+ const key = useMemo(() => configKey(props), [
105
+ props.preset,
106
+ props.lamps,
107
+ props.lampGain,
108
+ props.background,
109
+ props.transparentBackground,
110
+ props.clearGlass,
111
+ props.post,
112
+ props.motion,
113
+ props.scatter,
114
+ props.orbit,
115
+ props.quality,
116
+ props.dprMax,
117
+ props.paused,
118
+ props.config
119
+ ]);
120
+ const appliedKeyRef = useRef(null);
121
+ useImperativeHandle(ref, () => ({
122
+ get state() {
123
+ return handleRef.current?.state ?? IDLE_STATE;
124
+ },
125
+ get renderer() {
126
+ return handleRef.current?.renderer ?? null;
127
+ },
128
+ snapshot: (opts) => handleRef.current?.snapshot(opts) ?? Promise.resolve(null),
129
+ set: (config) => handleRef.current?.set(config),
130
+ play: () => handleRef.current?.play(),
131
+ pause: () => handleRef.current?.pause(),
132
+ destroy: () => handleRef.current?.destroy()
133
+ }), []);
134
+ useEffect(() => {
135
+ const container = containerRef.current;
136
+ if (!container) return;
137
+ let cancelled = false;
138
+ const options = {
139
+ poster: props.poster,
140
+ posterFit: props.posterFit,
141
+ lazy: props.lazy,
142
+ webgl: props.webgl,
143
+ renderer: props.renderer,
144
+ respectReducedMotion: props.respectReducedMotion,
145
+ minSizeForWebGL: props.minSizeForWebGL,
146
+ onReady: (r) => cbRef.current.onReady?.(r),
147
+ onFallback: (reason) => cbRef.current.onFallback?.(reason)
148
+ };
149
+ const handle = createMaterials(container, buildConfig(syncBase(props.preset), props), options);
150
+ handleRef.current = handle;
151
+ appliedKeyRef.current = key;
152
+ if (typeof props.preset === "string") resolveBase(props.preset).then((base) => {
153
+ if (!cancelled && handleRef.current === handle) handle.set(buildConfig(base, props));
154
+ });
155
+ return () => {
156
+ cancelled = true;
157
+ handle.destroy();
158
+ handleRef.current = null;
159
+ appliedKeyRef.current = null;
160
+ };
161
+ }, []);
162
+ useEffect(() => {
163
+ const handle = handleRef.current;
164
+ if (!handle || appliedKeyRef.current === key) return;
165
+ appliedKeyRef.current = key;
166
+ const apply = (base) => {
167
+ if (handleRef.current === handle) handle.set(buildConfig(base, props));
168
+ };
169
+ if (typeof props.preset === "string") resolveBase(props.preset).then(apply);
170
+ else apply(syncBase(props.preset));
171
+ }, [key]);
172
+ return /* @__PURE__ */ jsx("div", {
173
+ ref: containerRef,
174
+ className,
175
+ style,
176
+ children
177
+ });
178
+ }
179
+ const Materials3DWithRef = forwardRef(Materials3DInner);
180
+ Materials3DWithRef.displayName = "Materials3D";
181
+ /**
182
+ * A drop-in, self-optimizing Materials3D scene. Renders a `<div>` (SSR-safe; pass an
183
+ * `<img data-materials3d-poster>` child for a server-rendered poster) and, on the client, mounts the
184
+ * shell: poster-first, lazy, WebGL/reduced-motion/save-data aware, with the engine code-split out.
185
+ *
186
+ * A `ref` receives the {@link MaterialHandle} (`state`, `renderer`, `snapshot()`, `set()`,
187
+ * `play()`, `pause()`), on React 18 and 19 alike. `forwardRef` keeps the generic engine parameter
188
+ * only through this cast, which is why the component is declared this way.
189
+ */
190
+ const Materials3D = Materials3DWithRef;
191
+ //#endregion
192
+ export { Materials3D, Materials3D as default };
193
+
194
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.tsx"],"sourcesContent":["\"use client\";\n\nimport { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from \"react\";\nimport type { CSSProperties, ForwardedRef, ReactElement, ReactNode, Ref } from \"react\";\nimport { createDefaultConfig, createMaterials, mergeSceneConfig } from \"@materials3d/core\";\nimport type {\n FallbackReason,\n SceneConfig,\n MaterialHandle,\n MaterialOptions,\n LampConfig,\n MotionConfig,\n PosterFit,\n RendererKind,\n EngineFor,\n PostConfig,\n ScatterConfig,\n} from \"@materials3d/core\";\n\n/** Flat props for the settings people actually reach for; anything else goes through `config`. */\ninterface FlatProps {\n /** The bounded field of light behind the glass. */\n lamps?: LampConfig[];\n lampGain?: number;\n background?: string;\n /** Drop the backdrop so the scene composites over the page behind it. */\n transparentBackground?: boolean;\n clearGlass?: string;\n post?: Partial<PostConfig>;\n /** Motion for the shapes a `scatter` generates. Per-shape motion on hand-authored `items` goes\n * through the `config` prop, a flat prop can only mean \"all of them\". */\n motion?: Partial<MotionConfig>;\n scatter?: Partial<ScatterConfig>;\n orbit?: boolean;\n quality?: number;\n dprMax?: number;\n paused?: boolean;\n}\n\nexport interface Materials3DProps<R extends RendererKind = \"webgl\"> extends FlatProps {\n /** A preset: a function (tree-shakeable) or a name string (lazy-imports the presets chunk). */\n preset?: string | (() => Partial<SceneConfig>);\n /**\n * Escape hatch: a full/partial config, applied last and merged one level deep, so\n * `config={{ post: { bloom: 1 } }}` keeps the rest of the post block.\n * Precedence: default ← preset ← flat props ← config.\n */\n config?: Partial<SceneConfig>;\n poster?: string;\n /** Poster `object-fit`. Default `\"fill\"` (matches the canvas → seamless handoff). */\n posterFit?: PosterFit;\n lazy?: boolean;\n webgl?: \"auto\" | \"force\" | \"off\";\n /**\n * Which engine build to fetch. Default `\"webgl\"`.\n *\n * `\"webgpu\"` fetches a separate, larger bundle, three's node renderer and TSL, and is the only\n * way to reach a WebGPU backend. It selects the ENGINE, not the backend: it still runs on WebGL\n * where the browser has no WebGPU. See `MaterialOptions.renderer`.\n */\n renderer?: R;\n respectReducedMotion?: boolean;\n /** Stay a poster below this container size in CSS pixels. Four passes at phone DPR is real\n * work, and a still frame of glass loses less than a still frame of most things. */\n minSizeForWebGL?: number;\n className?: string;\n style?: CSSProperties;\n /** Custom SSR poster markup, e.g. `<img data-materials3d-poster src=\"…\" />`, the shell adopts it. */\n children?: ReactNode;\n onReady?: (renderer: EngineFor<R>) => void;\n onFallback?: (reason: FallbackReason) => void;\n}\n\n/** Resolve the base config: function preset (sync) → its config; string preset → lazy-load. */\nasync function resolveBase(preset: Materials3DProps[\"preset\"]): Promise<SceneConfig> {\n if (typeof preset === \"function\") return { ...createDefaultConfig(), ...preset() };\n if (typeof preset === \"string\") {\n const { PRESETS, isPresetName } = await import(\"@materials3d/core/presets\");\n if (isPresetName(preset)) return PRESETS[preset]();\n }\n return createDefaultConfig();\n}\n\n/** The base we can build synchronously (function preset / default); a string preset resolves later. */\nfunction syncBase(preset: Materials3DProps[\"preset\"]): SceneConfig {\n return typeof preset === \"function\"\n ? { ...createDefaultConfig(), ...preset() }\n : createDefaultConfig();\n}\n\n/** Apply the flat props (and the config escape hatch) onto a full base config. */\nfunction buildConfig(\n base: SceneConfig,\n props: Materials3DProps<RendererKind>,\n): Partial<SceneConfig> {\n // A function preset may hand back the same object on every call (a module-level constant, say),\n // and the base is spread only one level deep from it, so copy before writing into any block.\n const out = structuredClone(base);\n if (props.lamps !== undefined) out.lamps = props.lamps;\n if (props.lampGain !== undefined) out.lampGain = props.lampGain;\n if (props.background !== undefined) out.background = props.background;\n if (props.transparentBackground !== undefined) {\n out.transparentBackground = props.transparentBackground;\n }\n if (props.clearGlass !== undefined) out.clearGlass = props.clearGlass;\n if (props.post !== undefined) out.post = { ...out.post, ...props.post };\n if (props.motion !== undefined) {\n const motion = props.motion;\n if (out.scatter) out.scatter.motion = { ...out.scatter.motion, ...motion };\n for (const item of out.items) item.motion = { ...item.motion, ...motion };\n }\n // A partial scatter merges onto whatever the preset authored, so `scatter={{ count: 24 }}` is\n // enough to re-scatter the reference scene.\n if (props.scatter !== undefined && out.scatter) {\n out.scatter = { ...out.scatter, ...props.scatter };\n }\n if (props.orbit !== undefined) out.orbit = props.orbit;\n if (props.quality !== undefined) out.quality = props.quality;\n if (props.dprMax !== undefined) out.dprMax = props.dprMax;\n if (props.paused !== undefined) out.paused = props.paused;\n return props.config ? mergeSceneConfig(out, props.config) : out;\n}\n\n/**\n * Function presets are keyed by identity: swapping one function for another must re-run the update\n * effect, and a single string cannot say which function it saw. An inline (per-render) function\n * gets a fresh id each render, which re-runs the effect, `setConfig`'s structural diff makes that\n * a no-op, but memoize the preset if you want the effect to skip entirely.\n */\nconst presetIds = new WeakMap<object, number>();\nlet nextPresetId = 0;\nfunction presetKey(preset: Materials3DProps[\"preset\"]): string | undefined {\n if (typeof preset !== \"function\") return preset;\n let id = presetIds.get(preset);\n if (id === undefined) presetIds.set(preset, (id = nextPresetId++));\n return `fn#${id}`;\n}\n\n/** A stable string that changes whenever the resolved config would, keys the update effect. */\nfunction configKey(props: Materials3DProps<RendererKind>): string {\n const flat: Record<string, unknown> = {};\n const keys = [\n \"lamps\",\n \"lampGain\",\n \"background\",\n \"transparentBackground\",\n \"clearGlass\",\n \"post\",\n \"motion\",\n \"scatter\",\n \"orbit\",\n \"quality\",\n \"dprMax\",\n \"paused\",\n ] as const;\n for (const k of keys) if (props[k] !== undefined) flat[k] = props[k];\n return JSON.stringify({\n preset: presetKey(props.preset),\n flat,\n config: props.config,\n });\n}\n\n/** The state the handle reports before the shell exists: what the server-rendered div shows. */\nconst IDLE_STATE = \"poster\";\n\nfunction Materials3DInner(\n props: Materials3DProps<RendererKind>,\n ref: ForwardedRef<MaterialHandle<RendererKind>>,\n): ReactElement {\n const { className, style, children } = props;\n const containerRef = useRef<HTMLDivElement>(null);\n const handleRef = useRef<MaterialHandle<RendererKind> | null>(null);\n // Keep the latest callbacks in a ref so they never force a remount.\n const cbRef = useRef<Pick<Materials3DProps<RendererKind>, \"onReady\" | \"onFallback\">>({});\n cbRef.current.onReady = props.onReady;\n cbRef.current.onFallback = props.onFallback;\n\n // Serialising is not free (lamps, items, post), so it runs only when a flat prop changed.\n const key = useMemo(\n () => configKey(props),\n [\n props.preset,\n props.lamps,\n props.lampGain,\n props.background,\n props.transparentBackground,\n props.clearGlass,\n props.post,\n props.motion,\n props.scatter,\n props.orbit,\n props.quality,\n props.dprMax,\n props.paused,\n props.config,\n ],\n );\n // The key the live handle was last given, so the update effect can tell a real change from its\n // own first run (and from StrictMode's replay), both of which the mount effect already covered.\n const appliedKeyRef = useRef<string | null>(null);\n\n // A stable object that delegates to whichever handle is live, so a ref taken before the shell\n // mounted, or across a StrictMode remount, keeps working.\n useImperativeHandle(\n ref,\n () => ({\n get state() {\n return handleRef.current?.state ?? IDLE_STATE;\n },\n get renderer() {\n return handleRef.current?.renderer ?? null;\n },\n snapshot: (opts) => handleRef.current?.snapshot(opts) ?? Promise.resolve(null),\n set: (config) => handleRef.current?.set(config),\n play: () => handleRef.current?.play(),\n pause: () => handleRef.current?.pause(),\n destroy: () => handleRef.current?.destroy(),\n }),\n [],\n );\n\n // Mount once. StrictMode double-mount is safe: destroy() aborts a pending upgrade, and the\n // pre-upgrade create/destroy is DOM-only (poster + IntersectionObserver).\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n let cancelled = false;\n const options: MaterialOptions<RendererKind> = {\n poster: props.poster,\n posterFit: props.posterFit,\n lazy: props.lazy,\n webgl: props.webgl,\n renderer: props.renderer,\n respectReducedMotion: props.respectReducedMotion,\n minSizeForWebGL: props.minSizeForWebGL,\n onReady: (r) => cbRef.current.onReady?.(r),\n onFallback: (reason) => cbRef.current.onFallback?.(reason),\n };\n const handle = createMaterials<RendererKind>(\n container,\n buildConfig(syncBase(props.preset), props),\n options,\n );\n handleRef.current = handle;\n appliedKeyRef.current = key;\n // A string preset resolves asynchronously; stage the real config once it loads. This is the\n // one set a mount makes: the update effect below sees the key it already applied and skips.\n if (typeof props.preset === \"string\") {\n void resolveBase(props.preset).then((base) => {\n if (!cancelled && handleRef.current === handle) handle.set(buildConfig(base, props));\n });\n }\n return () => {\n cancelled = true;\n handle.destroy();\n handleRef.current = null;\n appliedKeyRef.current = null;\n };\n // Mount-time only (options are captured once; config changes flow through the effect below).\n }, []);\n\n // Push config changes (flat props / config / preset) to the live handle.\n useEffect(() => {\n const handle = handleRef.current;\n if (!handle || appliedKeyRef.current === key) return;\n appliedKeyRef.current = key;\n const apply = (base: SceneConfig): void => {\n if (handleRef.current === handle) handle.set(buildConfig(base, props));\n };\n if (typeof props.preset === \"string\") void resolveBase(props.preset).then(apply);\n else apply(syncBase(props.preset));\n // Re-runs only when the serialized config (`key`) changes; `props` is read fresh inside.\n }, [key]);\n\n return (\n <div ref={containerRef} className={className} style={style}>\n {children}\n </div>\n );\n}\n\nconst Materials3DWithRef = forwardRef(Materials3DInner);\nMaterials3DWithRef.displayName = \"Materials3D\";\n\n/**\n * A drop-in, self-optimizing Materials3D scene. Renders a `<div>` (SSR-safe; pass an\n * `<img data-materials3d-poster>` child for a server-rendered poster) and, on the client, mounts the\n * shell: poster-first, lazy, WebGL/reduced-motion/save-data aware, with the engine code-split out.\n *\n * A `ref` receives the {@link MaterialHandle} (`state`, `renderer`, `snapshot()`, `set()`,\n * `play()`, `pause()`), on React 18 and 19 alike. `forwardRef` keeps the generic engine parameter\n * only through this cast, which is why the component is declared this way.\n */\nexport const Materials3D = Materials3DWithRef as unknown as <R extends RendererKind = \"webgl\">(\n props: Materials3DProps<R> & { ref?: Ref<MaterialHandle<R>> },\n) => ReactElement;\n\nexport default Materials3D;\nexport type {\n SceneConfig,\n MaterialHandle,\n FallbackReason,\n SnapshotOptions,\n} from \"@materials3d/core\";\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@materials3d/react",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in Materials3D scene for React: the <Materials3D> component",
5
+ "keywords": [
6
+ "background",
7
+ "component",
8
+ "glass",
9
+ "react",
10
+ "refraction",
11
+ "three",
12
+ "threejs",
13
+ "webgl"
14
+ ],
15
+ "homepage": "https://github.com/Amir-Abushanab/materials3d/tree/main/packages/react#readme",
16
+ "bugs": "https://github.com/Amir-Abushanab/materials3d/issues",
17
+ "license": "MIT",
18
+ "author": "Amir Abushanab (https://github.com/Amir-Abushanab)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Amir-Abushanab/materials3d.git",
22
+ "directory": "packages/react"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@materials3d/core": "^0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/react": "^19.0.0",
43
+ "react": "^19.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@types/react": ">=18",
47
+ "@types/three": ">=0.180",
48
+ "react": ">=18",
49
+ "three": ">=0.180 <1"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@types/react": {
53
+ "optional": true
54
+ },
55
+ "@types/three": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "engines": {
60
+ "node": ">=20.19"
61
+ },
62
+ "scripts": {
63
+ "build": "tsdown",
64
+ "typecheck": "tsc --noEmit"
65
+ },
66
+ "main": "./dist/index.js",
67
+ "types": "./dist/index.d.ts"
68
+ }