mujoco-react 8.9.2 → 8.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/README.md +82 -1
- package/dist/chunk-SEWQULWO.js +400 -0
- package/dist/chunk-SEWQULWO.js.map +1 -0
- package/dist/index.d.ts +114 -744
- package/dist/index.js +329 -35
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +53 -0
- package/dist/spark.js +235 -0
- package/dist/spark.js.map +1 -0
- package/dist/types-BmneHLBM.d.ts +871 -0
- package/dist/vite.d.ts +9 -0
- package/dist/vite.js +4 -0
- package/dist/vite.js.map +1 -1
- package/package.json +15 -2
- package/src/components/Body.tsx +3 -1
- package/src/components/VisualScenario.tsx +566 -0
- package/src/core/MujocoCanvas.tsx +8 -1
- package/src/core/SceneLoader.ts +182 -3
- package/src/hooks/useFrameCapture.ts +206 -0
- package/src/hooks/usePolicy.ts +12 -8
- package/src/hooks/useSceneLights.ts +49 -18
- package/src/index.ts +48 -0
- package/src/spark.tsx +336 -0
- package/src/types.ts +159 -3
- package/src/vite.ts +8 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useThree } from '@react-three/fiber';
|
|
7
|
+
import type { ThreeElements } from '@react-three/fiber';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { useEffect, useMemo } from 'react';
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import type {
|
|
12
|
+
PairedSplatEnvironmentConfig,
|
|
13
|
+
ScenarioMaterialConfig,
|
|
14
|
+
SceneConfig,
|
|
15
|
+
SplatCollisionProxyConfig,
|
|
16
|
+
SplatEnvironmentMetadata,
|
|
17
|
+
SplatEnvironmentMetadataInput,
|
|
18
|
+
SplatFormat,
|
|
19
|
+
SplatRendererKind,
|
|
20
|
+
SplatSceneInput,
|
|
21
|
+
ScenarioLightingPreset,
|
|
22
|
+
ScenarioLightingProps,
|
|
23
|
+
SplatEnvironmentProps,
|
|
24
|
+
VisualScenarioConfig,
|
|
25
|
+
VisualScenarioEffectsProps,
|
|
26
|
+
} from '../types';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_BACKGROUND = '#181a1f';
|
|
29
|
+
|
|
30
|
+
export function ScenarioLighting({
|
|
31
|
+
preset = 'studio',
|
|
32
|
+
castShadow = true,
|
|
33
|
+
intensity = 1,
|
|
34
|
+
}: ScenarioLightingProps) {
|
|
35
|
+
if (preset === 'warehouse') {
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<ambientLight intensity={0.18 * intensity} />
|
|
39
|
+
<directionalLight
|
|
40
|
+
position={[3.5, -2, 5]}
|
|
41
|
+
intensity={2.2 * intensity}
|
|
42
|
+
castShadow={castShadow}
|
|
43
|
+
/>
|
|
44
|
+
<directionalLight position={[-2, 1.5, 2.5]} intensity={0.25 * intensity} />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (preset === 'low-light') {
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<ambientLight intensity={0.08 * intensity} />
|
|
53
|
+
<directionalLight
|
|
54
|
+
position={[2, -2, 3]}
|
|
55
|
+
intensity={0.75 * intensity}
|
|
56
|
+
castShadow={castShadow}
|
|
57
|
+
/>
|
|
58
|
+
<pointLight position={[-0.5, -0.8, 1.3]} intensity={0.6 * intensity} />
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (preset === 'splat') {
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<ambientLight intensity={0.42 * intensity} />
|
|
67
|
+
<directionalLight
|
|
68
|
+
position={[1.8, -2.4, 3.5]}
|
|
69
|
+
intensity={1.2 * intensity}
|
|
70
|
+
castShadow={castShadow}
|
|
71
|
+
/>
|
|
72
|
+
<pointLight position={[0.4, 0.2, 1.4]} intensity={0.35 * intensity} />
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<ambientLight intensity={0.35 * intensity} />
|
|
80
|
+
<directionalLight
|
|
81
|
+
position={[2.5, -3, 4]}
|
|
82
|
+
intensity={1.6 * intensity}
|
|
83
|
+
castShadow={castShadow}
|
|
84
|
+
/>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getScenarioBackground(
|
|
90
|
+
preset: ScenarioLightingPreset | undefined,
|
|
91
|
+
fallback = DEFAULT_BACKGROUND
|
|
92
|
+
) {
|
|
93
|
+
if (preset === 'warehouse') return '#20242b';
|
|
94
|
+
if (preset === 'low-light') return '#0f1115';
|
|
95
|
+
if (preset === 'splat') return '#1b1f24';
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getScenarioCameraPosition(
|
|
100
|
+
basePosition: readonly [number, number, number],
|
|
101
|
+
scenario?: Pick<VisualScenarioConfig, 'camera'>
|
|
102
|
+
): [number, number, number] {
|
|
103
|
+
const [x, y, z] = basePosition;
|
|
104
|
+
const jitter = scenario?.camera?.jitter ?? 0;
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
Number((x + jitter * 0.6).toFixed(3)),
|
|
108
|
+
Number((y - jitter * 0.4).toFixed(3)),
|
|
109
|
+
Number((z + jitter * 0.25).toFixed(3)),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function VisualScenarioEffects(props: VisualScenarioEffectsProps) {
|
|
114
|
+
useVisualScenarioEffects(props);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useVisualScenarioEffects({
|
|
119
|
+
scenario,
|
|
120
|
+
enabled = true,
|
|
121
|
+
applyBackground = true,
|
|
122
|
+
applyFog = true,
|
|
123
|
+
applyRenderer = true,
|
|
124
|
+
applyMaterials = true,
|
|
125
|
+
background,
|
|
126
|
+
fogNear,
|
|
127
|
+
fogFar,
|
|
128
|
+
materialFilter,
|
|
129
|
+
}: VisualScenarioEffectsProps) {
|
|
130
|
+
const { gl, scene, invalidate } = useThree();
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!enabled || !scenario) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const previousExposure = gl.toneMappingExposure;
|
|
138
|
+
const previousBackground = scene.background;
|
|
139
|
+
const previousFog = scene.fog;
|
|
140
|
+
const materialSnapshots = new Map<
|
|
141
|
+
THREE.Material,
|
|
142
|
+
{
|
|
143
|
+
color?: THREE.Color;
|
|
144
|
+
roughness?: number;
|
|
145
|
+
metalness?: number;
|
|
146
|
+
}
|
|
147
|
+
>();
|
|
148
|
+
|
|
149
|
+
if (applyRenderer) {
|
|
150
|
+
gl.toneMappingExposure = scenario.camera?.exposure ?? 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (applyBackground) {
|
|
154
|
+
scene.background = new THREE.Color(
|
|
155
|
+
background ?? getScenarioBackground(scenario.lighting)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (applyFog) {
|
|
160
|
+
scene.fog = createScenarioFog(scenario, background, fogNear, fogFar);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (applyMaterials && scenario.materials) {
|
|
164
|
+
applyScenarioMaterials(scene, scenario, materialSnapshots, materialFilter);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
invalidate();
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
gl.toneMappingExposure = previousExposure;
|
|
171
|
+
scene.background = previousBackground;
|
|
172
|
+
scene.fog = previousFog;
|
|
173
|
+
|
|
174
|
+
for (const [material, snapshot] of materialSnapshots) {
|
|
175
|
+
const mutable = getMutableScenarioMaterial(material);
|
|
176
|
+
if (!mutable) continue;
|
|
177
|
+
if (snapshot.color) mutable.color.copy(snapshot.color);
|
|
178
|
+
if (typeof snapshot.roughness === 'number') {
|
|
179
|
+
mutable.roughness = snapshot.roughness;
|
|
180
|
+
}
|
|
181
|
+
if (typeof snapshot.metalness === 'number') {
|
|
182
|
+
mutable.metalness = snapshot.metalness;
|
|
183
|
+
}
|
|
184
|
+
mutable.needsUpdate = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
invalidate();
|
|
188
|
+
};
|
|
189
|
+
}, [
|
|
190
|
+
applyBackground,
|
|
191
|
+
applyFog,
|
|
192
|
+
applyMaterials,
|
|
193
|
+
applyRenderer,
|
|
194
|
+
background,
|
|
195
|
+
enabled,
|
|
196
|
+
fogFar,
|
|
197
|
+
fogNear,
|
|
198
|
+
gl,
|
|
199
|
+
invalidate,
|
|
200
|
+
materialFilter,
|
|
201
|
+
scenario,
|
|
202
|
+
scene,
|
|
203
|
+
]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Renderer-agnostic Gaussian splat environment boundary.
|
|
208
|
+
*
|
|
209
|
+
* This component intentionally does not import a specific 3DGS renderer. Pass a
|
|
210
|
+
* Spark/GaussianSplats3D object as `children` once the app chooses a renderer,
|
|
211
|
+
* and pass MuJoCo/MJCF collision proxy visuals via `collisionProxy`.
|
|
212
|
+
*/
|
|
213
|
+
export function SplatEnvironment({
|
|
214
|
+
environment,
|
|
215
|
+
scenario,
|
|
216
|
+
renderer,
|
|
217
|
+
src,
|
|
218
|
+
format,
|
|
219
|
+
collisionProxy,
|
|
220
|
+
collisionProxyMetadata,
|
|
221
|
+
children,
|
|
222
|
+
showPlaceholder = true,
|
|
223
|
+
...groupProps
|
|
224
|
+
}: SplatEnvironmentProps) {
|
|
225
|
+
const metadata = useSplatEnvironment({
|
|
226
|
+
environment,
|
|
227
|
+
scenario,
|
|
228
|
+
renderer,
|
|
229
|
+
src,
|
|
230
|
+
format,
|
|
231
|
+
collisionProxy: collisionProxyMetadata,
|
|
232
|
+
});
|
|
233
|
+
const existingUserData =
|
|
234
|
+
typeof groupProps.userData === 'object' && groupProps.userData !== null
|
|
235
|
+
? groupProps.userData
|
|
236
|
+
: {};
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<group
|
|
240
|
+
{...groupProps}
|
|
241
|
+
userData={{
|
|
242
|
+
...existingUserData,
|
|
243
|
+
...metadata.userData,
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{children}
|
|
247
|
+
{children || !showPlaceholder ? null : <SplatPlaceholder />}
|
|
248
|
+
{collisionProxy}
|
|
249
|
+
</group>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function useSplatEnvironment({
|
|
254
|
+
environment,
|
|
255
|
+
scenario,
|
|
256
|
+
renderer,
|
|
257
|
+
src,
|
|
258
|
+
format,
|
|
259
|
+
collisionProxy,
|
|
260
|
+
}: SplatEnvironmentMetadataInput): SplatEnvironmentMetadata {
|
|
261
|
+
const scenarioEnvironment = useMemo(
|
|
262
|
+
() =>
|
|
263
|
+
environment ??
|
|
264
|
+
(scenario
|
|
265
|
+
? createPairedSplatEnvironment(scenario, { renderer })
|
|
266
|
+
: undefined),
|
|
267
|
+
[environment, renderer, scenario]
|
|
268
|
+
);
|
|
269
|
+
const resolvedSrc = src ?? scenarioEnvironment?.splat.src ?? scenario?.splat?.src;
|
|
270
|
+
const resolvedFormat =
|
|
271
|
+
format ??
|
|
272
|
+
scenarioEnvironment?.splat.format ??
|
|
273
|
+
scenario?.splat?.format ??
|
|
274
|
+
'spz';
|
|
275
|
+
const resolvedCollisionProxy =
|
|
276
|
+
collisionProxy ??
|
|
277
|
+
scenarioEnvironment?.collisionProxy ??
|
|
278
|
+
scenario?.splat?.collisionProxy ??
|
|
279
|
+
undefined;
|
|
280
|
+
|
|
281
|
+
return useMemo(
|
|
282
|
+
() => ({
|
|
283
|
+
src: resolvedSrc,
|
|
284
|
+
format: resolvedFormat,
|
|
285
|
+
collisionProxy: resolvedCollisionProxy,
|
|
286
|
+
userData: createSplatEnvironmentUserData({
|
|
287
|
+
environment: scenarioEnvironment,
|
|
288
|
+
src: resolvedSrc,
|
|
289
|
+
format: resolvedFormat,
|
|
290
|
+
collisionProxy: resolvedCollisionProxy,
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
[scenarioEnvironment, resolvedSrc, resolvedFormat, resolvedCollisionProxy]
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Convert a generic visual scenario splat block into a paired visual/physics
|
|
299
|
+
* environment config. Returns undefined until both the splat asset and MJCF
|
|
300
|
+
* collision proxy are present.
|
|
301
|
+
*/
|
|
302
|
+
export function createPairedSplatEnvironment(
|
|
303
|
+
scenario: Pick<VisualScenarioConfig, 'id' | 'label' | 'environment' | 'splat'>,
|
|
304
|
+
options: {
|
|
305
|
+
id?: string;
|
|
306
|
+
label?: string;
|
|
307
|
+
description?: string;
|
|
308
|
+
renderer?: SplatRendererKind;
|
|
309
|
+
} = {}
|
|
310
|
+
): PairedSplatEnvironmentConfig | undefined {
|
|
311
|
+
const splat = scenario.splat;
|
|
312
|
+
const collisionProxy = splat?.collisionProxy;
|
|
313
|
+
|
|
314
|
+
if (!splat?.enabled || !splat.src || !collisionProxy?.xmlPath) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
id: options.id ?? scenario.id ?? 'splat-environment',
|
|
320
|
+
label: options.label ?? scenario.label ?? 'Gaussian splat environment',
|
|
321
|
+
description:
|
|
322
|
+
options.description ??
|
|
323
|
+
(scenario.environment
|
|
324
|
+
? `Visual ${scenario.environment} splat paired with MJCF collision proxy.`
|
|
325
|
+
: undefined),
|
|
326
|
+
splat: {
|
|
327
|
+
src: splat.src,
|
|
328
|
+
format: splat.format ?? 'spz',
|
|
329
|
+
renderer: options.renderer,
|
|
330
|
+
},
|
|
331
|
+
collisionProxy: {
|
|
332
|
+
...collisionProxy,
|
|
333
|
+
xmlPath: collisionProxy.xmlPath,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isPairedSplatEnvironment(input: SplatSceneInput): input is PairedSplatEnvironmentConfig {
|
|
339
|
+
return !!input && 'collisionProxy' in input && 'splat' in input;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function sceneRelativePath(sceneConfig: SceneConfig, path: string): string {
|
|
343
|
+
const src = sceneConfig.src;
|
|
344
|
+
if (!src) return path;
|
|
345
|
+
|
|
346
|
+
const base = src.endsWith('/') ? src : src + '/';
|
|
347
|
+
if (path.startsWith(base)) return path.slice(base.length);
|
|
348
|
+
return path;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function uniquePaths(paths: readonly string[]): string[] {
|
|
352
|
+
const seen = new Set<string>();
|
|
353
|
+
const result: string[] = [];
|
|
354
|
+
for (const path of paths) {
|
|
355
|
+
if (seen.has(path)) continue;
|
|
356
|
+
seen.add(path);
|
|
357
|
+
result.push(path);
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Compose a MuJoCo scene config with a paired splat collision proxy.
|
|
364
|
+
*
|
|
365
|
+
* This keeps the common hybrid setup declarative:
|
|
366
|
+
* robot XML remains `sceneFile`, the `.spz` remains a visual-only layer, and
|
|
367
|
+
* the paired MJCF collision proxy is added to `environmentFiles`.
|
|
368
|
+
*/
|
|
369
|
+
export function withSplatEnvironment(
|
|
370
|
+
sceneConfig: SceneConfig,
|
|
371
|
+
input: SplatSceneInput,
|
|
372
|
+
options: { renderer?: SplatRendererKind } = {}
|
|
373
|
+
): SceneConfig {
|
|
374
|
+
const environment = isPairedSplatEnvironment(input)
|
|
375
|
+
? input
|
|
376
|
+
: input
|
|
377
|
+
? createPairedSplatEnvironment(input, options)
|
|
378
|
+
: undefined;
|
|
379
|
+
const xmlPath = environment?.collisionProxy.xmlPath;
|
|
380
|
+
if (!xmlPath) return sceneConfig;
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
...sceneConfig,
|
|
384
|
+
environmentFiles: uniquePaths([
|
|
385
|
+
...(sceneConfig.environmentFiles ?? []),
|
|
386
|
+
sceneRelativePath(sceneConfig, xmlPath),
|
|
387
|
+
]),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function createSplatEnvironmentUserData({
|
|
392
|
+
environment,
|
|
393
|
+
src,
|
|
394
|
+
format = 'spz',
|
|
395
|
+
collisionProxy,
|
|
396
|
+
}: {
|
|
397
|
+
environment?: PairedSplatEnvironmentConfig;
|
|
398
|
+
src?: string;
|
|
399
|
+
format?: SplatFormat;
|
|
400
|
+
collisionProxy?: SplatCollisionProxyConfig;
|
|
401
|
+
}) {
|
|
402
|
+
return {
|
|
403
|
+
role: 'splat-environment',
|
|
404
|
+
environmentId: environment?.id,
|
|
405
|
+
environmentLabel: environment?.label,
|
|
406
|
+
splatSrc: src,
|
|
407
|
+
splatFormat: format,
|
|
408
|
+
splatRenderer: environment?.splat.renderer,
|
|
409
|
+
collisionProxyStatus: collisionProxy?.status ?? 'missing',
|
|
410
|
+
collisionProxyXmlPath: collisionProxy?.xmlPath,
|
|
411
|
+
collisionProxyPrimitives: collisionProxy?.primitives ?? [],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function createSparkSplatViewerUrl({
|
|
416
|
+
viewerUrl,
|
|
417
|
+
splatSrc,
|
|
418
|
+
}: {
|
|
419
|
+
viewerUrl: string;
|
|
420
|
+
splatSrc: string;
|
|
421
|
+
}) {
|
|
422
|
+
const url = new URL(viewerUrl, 'http://mujoco-react.local');
|
|
423
|
+
url.searchParams.set('splat', splatSrc);
|
|
424
|
+
return viewerUrl.startsWith('http') ? url.toString() : `${url.pathname}${url.search}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function SplatPlaceholder() {
|
|
428
|
+
return (
|
|
429
|
+
<group>
|
|
430
|
+
<mesh position={[0, 0, 1.2]}>
|
|
431
|
+
<boxGeometry args={[2.4, 2.4, 2.4]} />
|
|
432
|
+
<meshBasicMaterial
|
|
433
|
+
color="#8b8b8b"
|
|
434
|
+
transparent
|
|
435
|
+
opacity={0.06}
|
|
436
|
+
wireframe
|
|
437
|
+
side={THREE.DoubleSide}
|
|
438
|
+
/>
|
|
439
|
+
</mesh>
|
|
440
|
+
</group>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function createScenarioFog(
|
|
445
|
+
scenario: VisualScenarioConfig,
|
|
446
|
+
background: THREE.ColorRepresentation | undefined,
|
|
447
|
+
fogNear: number | undefined,
|
|
448
|
+
fogFar: number | undefined
|
|
449
|
+
) {
|
|
450
|
+
if (scenario.lighting === 'low-light') {
|
|
451
|
+
return new THREE.Fog(
|
|
452
|
+
background ?? getScenarioBackground(scenario.lighting),
|
|
453
|
+
fogNear ?? 2.5,
|
|
454
|
+
fogFar ?? 9
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (scenario.lighting === 'warehouse') {
|
|
459
|
+
return new THREE.Fog(
|
|
460
|
+
background ?? getScenarioBackground(scenario.lighting),
|
|
461
|
+
fogNear ?? 5,
|
|
462
|
+
fogFar ?? 16
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function applyScenarioMaterials(
|
|
470
|
+
scene: THREE.Scene,
|
|
471
|
+
scenario: VisualScenarioConfig,
|
|
472
|
+
snapshots: Map<
|
|
473
|
+
THREE.Material,
|
|
474
|
+
{
|
|
475
|
+
color?: THREE.Color;
|
|
476
|
+
roughness?: number;
|
|
477
|
+
metalness?: number;
|
|
478
|
+
}
|
|
479
|
+
>,
|
|
480
|
+
materialFilter: VisualScenarioEffectsProps['materialFilter']
|
|
481
|
+
) {
|
|
482
|
+
const materials = scenario.materials;
|
|
483
|
+
if (!materials) return;
|
|
484
|
+
|
|
485
|
+
scene.traverse((object) => {
|
|
486
|
+
if (!(object instanceof THREE.Mesh)) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
for (const material of normalizeMaterials(object.material)) {
|
|
491
|
+
const mutable = getMutableScenarioMaterial(material);
|
|
492
|
+
if (!mutable) continue;
|
|
493
|
+
if (materialFilter && !materialFilter(object, material)) continue;
|
|
494
|
+
|
|
495
|
+
if (!snapshots.has(material)) {
|
|
496
|
+
snapshots.set(material, {
|
|
497
|
+
color: mutable.color.clone(),
|
|
498
|
+
roughness: mutable.roughness,
|
|
499
|
+
metalness: mutable.metalness,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
applyScenarioMaterial(mutable, object, scenario, materials);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function applyScenarioMaterial(
|
|
509
|
+
material: THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial,
|
|
510
|
+
object: THREE.Object3D,
|
|
511
|
+
scenario: VisualScenarioConfig,
|
|
512
|
+
materials: ScenarioMaterialConfig
|
|
513
|
+
) {
|
|
514
|
+
const seed = scenario.seed ?? 0;
|
|
515
|
+
const objectKey = `${scenario.id ?? 'scenario'}:${object.name}:${material.name}:${seed}`;
|
|
516
|
+
const variation = hashToUnitInterval(objectKey);
|
|
517
|
+
|
|
518
|
+
if (materials.randomizeObjectColors) {
|
|
519
|
+
material.color.setHSL(variation, 0.38, 0.42);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (materials.randomizeTableMaterial) {
|
|
523
|
+
material.roughness = clamp01(
|
|
524
|
+
materials.roughness ?? 0.35 + variation * 0.45
|
|
525
|
+
);
|
|
526
|
+
material.metalness = clamp01(
|
|
527
|
+
materials.metalness ?? variation * 0.12
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
material.needsUpdate = true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function normalizeMaterials(
|
|
535
|
+
material: THREE.Material | THREE.Material[]
|
|
536
|
+
): THREE.Material[] {
|
|
537
|
+
return Array.isArray(material) ? material : [material];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function getMutableScenarioMaterial(
|
|
541
|
+
material: THREE.Material
|
|
542
|
+
): THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial | null {
|
|
543
|
+
if (
|
|
544
|
+
material instanceof THREE.MeshStandardMaterial ||
|
|
545
|
+
material instanceof THREE.MeshPhysicalMaterial
|
|
546
|
+
) {
|
|
547
|
+
return material;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function hashToUnitInterval(value: string) {
|
|
554
|
+
let hash = 2166136261;
|
|
555
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
556
|
+
hash ^= value.charCodeAt(index);
|
|
557
|
+
hash = Math.imul(hash, 16777619);
|
|
558
|
+
}
|
|
559
|
+
return (hash >>> 0) / 4294967295;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function clamp01(value: number) {
|
|
563
|
+
return Math.max(0, Math.min(1, value));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export type SplatCollisionProxy = ReactNode | ThreeElements['group'];
|
|
@@ -31,6 +31,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
|
|
|
31
31
|
paused,
|
|
32
32
|
speed,
|
|
33
33
|
interpolate,
|
|
34
|
+
loadingFallback,
|
|
34
35
|
children,
|
|
35
36
|
...canvasProps
|
|
36
37
|
},
|
|
@@ -44,7 +45,13 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
|
|
|
44
45
|
}
|
|
45
46
|
}, [wasmStatus, wasmError, onError]);
|
|
46
47
|
|
|
47
|
-
if (wasmStatus === '
|
|
48
|
+
if (wasmStatus === 'loading' || !mujoco) {
|
|
49
|
+
return loadingFallback ? (
|
|
50
|
+
<Canvas {...canvasProps}>{loadingFallback}</Canvas>
|
|
51
|
+
) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (wasmStatus === 'error') {
|
|
48
55
|
return null;
|
|
49
56
|
}
|
|
50
57
|
|