@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 +21 -0
- package/README.md +85 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +194 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|