mujoco-react 9.2.0 → 9.3.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 +96 -10
- package/dist/{chunk-33CV6HSV.js → chunk-T3GVZJ4F.js} +222 -8
- package/dist/chunk-T3GVZJ4F.js.map +1 -0
- package/dist/index.d.ts +158 -4
- package/dist/index.js +692 -130
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +24 -2
- package/dist/spark.js +89 -3
- package/dist/spark.js.map +1 -1
- package/dist/{types-S8ggQY2n.d.ts → types-oxbxOkAx.d.ts} +120 -3
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +6 -3
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/components/VisualScenario.tsx +178 -1
- package/src/core/MujocoSimProvider.tsx +373 -29
- package/src/core/SceneLoader.ts +13 -0
- package/src/hooks/useMountedCameraSequenceRecorder.ts +107 -0
- package/src/index.ts +49 -0
- package/src/rendering/cameraFrameCapture.ts +195 -26
- package/src/rendering/cameraFrameSource.ts +375 -0
- package/src/spark.tsx +144 -0
- package/src/types.ts +122 -2
- package/src/vite.ts +5 -2
- package/dist/chunk-33CV6HSV.js.map +0 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Helpers for resolving dataset camera streams to mounted MuJoCo resources.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Bodies,
|
|
10
|
+
CameraFrameCaptureOptions,
|
|
11
|
+
CameraFrameCaptureSource,
|
|
12
|
+
CameraFrameSequenceCamera,
|
|
13
|
+
CameraFrameSequenceOptions,
|
|
14
|
+
CameraFrameSequenceResult,
|
|
15
|
+
Cameras,
|
|
16
|
+
MujocoSimAPI,
|
|
17
|
+
Sites,
|
|
18
|
+
} from '../types';
|
|
19
|
+
|
|
20
|
+
export type MountedCameraFrameCaptureSource = Extract<
|
|
21
|
+
CameraFrameCaptureSource,
|
|
22
|
+
| { kind: 'mujoco-camera' }
|
|
23
|
+
| { kind: 'mujoco-site' }
|
|
24
|
+
| { kind: 'mujoco-body' }
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
export type CameraFrameMountSelector =
|
|
28
|
+
| { cameraName: Cameras; siteName?: never; bodyName?: never }
|
|
29
|
+
| { siteName: Sites; cameraName?: never; bodyName?: never }
|
|
30
|
+
| { bodyName: Bodies; cameraName?: never; siteName?: never };
|
|
31
|
+
|
|
32
|
+
export interface NamedCameraFrameResource {
|
|
33
|
+
name: string | null | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolveMountedCameraFrameSourceOptions {
|
|
37
|
+
cameras?: readonly (Cameras | NamedCameraFrameResource | null | undefined)[];
|
|
38
|
+
sites?: readonly (Sites | NamedCameraFrameResource | null | undefined)[];
|
|
39
|
+
bodies?: readonly (Bodies | NamedCameraFrameResource | null | undefined)[];
|
|
40
|
+
aliases?: Record<
|
|
41
|
+
string,
|
|
42
|
+
CameraFrameMountSelector | readonly CameraFrameMountSelector[]
|
|
43
|
+
>;
|
|
44
|
+
/**
|
|
45
|
+
* Accept the first valid alias selector even when the current resource
|
|
46
|
+
* inventory cannot verify it. This is useful when aliases come from a
|
|
47
|
+
* previously validated model inventory and the actual provider will validate
|
|
48
|
+
* again during capture.
|
|
49
|
+
*/
|
|
50
|
+
allowAliasFallback?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolvedMountedCameraFrameSource {
|
|
54
|
+
key: string;
|
|
55
|
+
selector: CameraFrameMountSelector;
|
|
56
|
+
source: MountedCameraFrameCaptureSource;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MountedCameraFrameSequenceDefaults = Omit<
|
|
60
|
+
CameraFrameSequenceCamera,
|
|
61
|
+
'key' | 'cameraName' | 'siteName' | 'bodyName' | 'source'
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
export type MountedCameraFrameSequenceCameraOptions = Partial<
|
|
65
|
+
MountedCameraFrameSequenceDefaults
|
|
66
|
+
>;
|
|
67
|
+
|
|
68
|
+
export interface CreateMountedCameraFrameSequencePlanOptions
|
|
69
|
+
extends ResolveMountedCameraFrameSourceOptions {
|
|
70
|
+
defaults?: MountedCameraFrameSequenceDefaults;
|
|
71
|
+
cameraOptions?: Record<string, MountedCameraFrameSequenceCameraOptions>;
|
|
72
|
+
requireAll?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MountedCameraFrameSequencePlan {
|
|
76
|
+
cameraKeys: string[];
|
|
77
|
+
cameras: CameraFrameSequenceCamera[];
|
|
78
|
+
resolved: Record<string, ResolvedMountedCameraFrameSource>;
|
|
79
|
+
missingKeys: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const MountedCameraFrameSequenceReadinessStatus = {
|
|
83
|
+
Ready: 'ready',
|
|
84
|
+
Partial: 'partial',
|
|
85
|
+
Missing: 'missing',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
export type MountedCameraFrameSequenceReadinessStatus =
|
|
89
|
+
(typeof MountedCameraFrameSequenceReadinessStatus)[keyof typeof MountedCameraFrameSequenceReadinessStatus];
|
|
90
|
+
|
|
91
|
+
export interface MountedCameraFrameSequenceSourceReadiness {
|
|
92
|
+
key: string;
|
|
93
|
+
ready: boolean;
|
|
94
|
+
selector?: CameraFrameMountSelector;
|
|
95
|
+
source?: MountedCameraFrameCaptureSource;
|
|
96
|
+
message: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MountedCameraFrameSequenceReadiness {
|
|
100
|
+
ready: boolean;
|
|
101
|
+
status: MountedCameraFrameSequenceReadinessStatus;
|
|
102
|
+
cameraKeys: string[];
|
|
103
|
+
resolvedKeys: string[];
|
|
104
|
+
missingKeys: string[];
|
|
105
|
+
cameras: Record<string, MountedCameraFrameSequenceSourceReadiness>;
|
|
106
|
+
message: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type MountedCameraFrameSequencePlanOptions = Omit<
|
|
110
|
+
CreateMountedCameraFrameSequencePlanOptions,
|
|
111
|
+
'cameras' | 'sites' | 'bodies'
|
|
112
|
+
>;
|
|
113
|
+
|
|
114
|
+
export interface MountedCameraFrameSequenceRecordOptions
|
|
115
|
+
extends Omit<CameraFrameSequenceOptions, 'cameras'>,
|
|
116
|
+
MountedCameraFrameSequencePlanOptions {
|
|
117
|
+
cameraKeys: readonly string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface MountedCameraFrameSequenceRecordResult
|
|
121
|
+
extends CameraFrameSequenceResult {
|
|
122
|
+
plan: MountedCameraFrameSequencePlan;
|
|
123
|
+
readiness: MountedCameraFrameSequenceReadiness;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type MountedCameraFrameSequenceRecorderTarget = Pick<
|
|
127
|
+
MujocoSimAPI,
|
|
128
|
+
'getCameras' | 'getSites' | 'getBodies' | 'recordCameraSequence'
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
function getResourceName(
|
|
132
|
+
resource: string | NamedCameraFrameResource | null | undefined
|
|
133
|
+
) {
|
|
134
|
+
if (!resource) return null;
|
|
135
|
+
return typeof resource === 'string' ? resource : resource.name ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createNameSet(
|
|
139
|
+
resources:
|
|
140
|
+
| readonly (string | NamedCameraFrameResource | null | undefined)[]
|
|
141
|
+
| undefined
|
|
142
|
+
) {
|
|
143
|
+
return new Set(
|
|
144
|
+
(resources ?? [])
|
|
145
|
+
.map((resource) => getResourceName(resource))
|
|
146
|
+
.filter((name): name is string => Boolean(name))
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeAliasCandidates(
|
|
151
|
+
value: CameraFrameMountSelector | readonly CameraFrameMountSelector[] | undefined
|
|
152
|
+
) {
|
|
153
|
+
if (!value) return [];
|
|
154
|
+
return Array.isArray(value) ? value : [value];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function countMountedSelectors(selector: CameraFrameMountSelector) {
|
|
158
|
+
return Number(Boolean(selector.cameraName)) +
|
|
159
|
+
Number(Boolean(selector.siteName)) +
|
|
160
|
+
Number(Boolean(selector.bodyName));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getMountedCameraFrameCaptureSource(
|
|
164
|
+
selector: CameraFrameMountSelector
|
|
165
|
+
): MountedCameraFrameCaptureSource | null {
|
|
166
|
+
if (countMountedSelectors(selector) !== 1) return null;
|
|
167
|
+
if (selector.cameraName) {
|
|
168
|
+
return { kind: 'mujoco-camera', cameraName: selector.cameraName };
|
|
169
|
+
}
|
|
170
|
+
if (selector.siteName) {
|
|
171
|
+
return { kind: 'mujoco-site', siteName: selector.siteName };
|
|
172
|
+
}
|
|
173
|
+
if (selector.bodyName) {
|
|
174
|
+
return { kind: 'mujoco-body', bodyName: selector.bodyName };
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isMountedCameraFrameCaptureSource(
|
|
180
|
+
source: CameraFrameCaptureSource
|
|
181
|
+
): source is MountedCameraFrameCaptureSource {
|
|
182
|
+
return (
|
|
183
|
+
source.kind === 'mujoco-camera' ||
|
|
184
|
+
source.kind === 'mujoco-site' ||
|
|
185
|
+
source.kind === 'mujoco-body'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getCameraFrameCaptureSourceTarget(
|
|
190
|
+
source: CameraFrameCaptureSource
|
|
191
|
+
) {
|
|
192
|
+
if (source.kind === 'mujoco-camera') return source.cameraName;
|
|
193
|
+
if (source.kind === 'mujoco-site') return source.siteName;
|
|
194
|
+
if (source.kind === 'mujoco-body') return source.bodyName;
|
|
195
|
+
if (source.kind === 'custom-camera') return 'custom camera';
|
|
196
|
+
if (source.kind === 'explicit-pose') return 'explicit pose';
|
|
197
|
+
return 'fallback camera';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isSelectorMounted(
|
|
201
|
+
selector: CameraFrameMountSelector,
|
|
202
|
+
cameraNames: Set<string>,
|
|
203
|
+
siteNames: Set<string>,
|
|
204
|
+
bodyNames: Set<string>
|
|
205
|
+
) {
|
|
206
|
+
if (countMountedSelectors(selector) !== 1) return false;
|
|
207
|
+
return (
|
|
208
|
+
(selector.cameraName ? cameraNames.has(selector.cameraName) : false) ||
|
|
209
|
+
(selector.siteName ? siteNames.has(selector.siteName) : false) ||
|
|
210
|
+
(selector.bodyName ? bodyNames.has(selector.bodyName) : false)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function resolveMountedCameraFrameSource(
|
|
215
|
+
key: string,
|
|
216
|
+
options: ResolveMountedCameraFrameSourceOptions
|
|
217
|
+
): ResolvedMountedCameraFrameSource | null {
|
|
218
|
+
const cameraNames = createNameSet(options.cameras);
|
|
219
|
+
const siteNames = createNameSet(options.sites);
|
|
220
|
+
const bodyNames = createNameSet(options.bodies);
|
|
221
|
+
const directCandidates: CameraFrameMountSelector[] = [
|
|
222
|
+
{ cameraName: key },
|
|
223
|
+
{ siteName: key },
|
|
224
|
+
{ bodyName: key },
|
|
225
|
+
];
|
|
226
|
+
const aliasCandidates = normalizeAliasCandidates(options.aliases?.[key]);
|
|
227
|
+
const candidates = [...directCandidates, ...aliasCandidates];
|
|
228
|
+
|
|
229
|
+
for (const selector of candidates) {
|
|
230
|
+
if (!isSelectorMounted(selector, cameraNames, siteNames, bodyNames)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const source = getMountedCameraFrameCaptureSource(selector);
|
|
234
|
+
if (!source) continue;
|
|
235
|
+
return { key, selector, source };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (options.allowAliasFallback) {
|
|
239
|
+
for (const selector of aliasCandidates) {
|
|
240
|
+
const source = getMountedCameraFrameCaptureSource(selector);
|
|
241
|
+
if (!source) continue;
|
|
242
|
+
return { key, selector, source };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createMountedCameraFrameSequencePlan(
|
|
250
|
+
cameraKeys: readonly string[],
|
|
251
|
+
options: CreateMountedCameraFrameSequencePlanOptions
|
|
252
|
+
): MountedCameraFrameSequencePlan {
|
|
253
|
+
const cameras: CameraFrameSequenceCamera[] = [];
|
|
254
|
+
const resolved: Record<string, ResolvedMountedCameraFrameSource> = {};
|
|
255
|
+
const missingKeys: string[] = [];
|
|
256
|
+
|
|
257
|
+
for (const key of cameraKeys) {
|
|
258
|
+
const mountedSource = resolveMountedCameraFrameSource(key, options);
|
|
259
|
+
if (!mountedSource) {
|
|
260
|
+
missingKeys.push(key);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
resolved[key] = mountedSource;
|
|
265
|
+
cameras.push({
|
|
266
|
+
key,
|
|
267
|
+
...options.defaults,
|
|
268
|
+
...options.cameraOptions?.[key],
|
|
269
|
+
...mountedSource.selector,
|
|
270
|
+
source: mountedSource.source,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (options.requireAll && missingKeys.length > 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Unable to resolve mounted MuJoCo camera source${
|
|
277
|
+
missingKeys.length === 1 ? '' : 's'
|
|
278
|
+
} for ${missingKeys.join(', ')}.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
cameraKeys: [...cameraKeys],
|
|
284
|
+
cameras,
|
|
285
|
+
resolved,
|
|
286
|
+
missingKeys,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function createMountedCameraFrameSequenceReadiness(
|
|
291
|
+
plan: MountedCameraFrameSequencePlan
|
|
292
|
+
): MountedCameraFrameSequenceReadiness {
|
|
293
|
+
const cameras: Record<string, MountedCameraFrameSequenceSourceReadiness> = {};
|
|
294
|
+
const resolvedKeys = plan.cameraKeys.filter((key) => Boolean(plan.resolved[key]));
|
|
295
|
+
|
|
296
|
+
for (const key of plan.cameraKeys) {
|
|
297
|
+
const resolved = plan.resolved[key];
|
|
298
|
+
cameras[key] = resolved
|
|
299
|
+
? {
|
|
300
|
+
key,
|
|
301
|
+
ready: true,
|
|
302
|
+
selector: resolved.selector,
|
|
303
|
+
source: resolved.source,
|
|
304
|
+
message: `Camera stream "${key}" resolves to ${resolved.source.kind}:${getCameraFrameCaptureSourceTarget(resolved.source)}.`,
|
|
305
|
+
}
|
|
306
|
+
: {
|
|
307
|
+
key,
|
|
308
|
+
ready: false,
|
|
309
|
+
message: `Camera stream "${key}" does not resolve to a mounted MuJoCo camera, site, or body.`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const missingKeys = [...plan.missingKeys];
|
|
314
|
+
const ready = missingKeys.length === 0;
|
|
315
|
+
const status: MountedCameraFrameSequenceReadinessStatus = ready
|
|
316
|
+
? MountedCameraFrameSequenceReadinessStatus.Ready
|
|
317
|
+
: resolvedKeys.length > 0
|
|
318
|
+
? MountedCameraFrameSequenceReadinessStatus.Partial
|
|
319
|
+
: MountedCameraFrameSequenceReadinessStatus.Missing;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
ready,
|
|
323
|
+
status,
|
|
324
|
+
cameraKeys: [...plan.cameraKeys],
|
|
325
|
+
resolvedKeys,
|
|
326
|
+
missingKeys,
|
|
327
|
+
cameras,
|
|
328
|
+
message: ready
|
|
329
|
+
? `All ${plan.cameraKeys.length} requested camera stream${
|
|
330
|
+
plan.cameraKeys.length === 1 ? '' : 's'
|
|
331
|
+
} resolve to mounted MuJoCo sources.`
|
|
332
|
+
: `Missing mounted MuJoCo source${
|
|
333
|
+
missingKeys.length === 1 ? '' : 's'
|
|
334
|
+
} for ${missingKeys.join(', ')}.`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function createMountedCameraFrameSequencePlanFromApi(
|
|
339
|
+
api: MountedCameraFrameSequenceRecorderTarget,
|
|
340
|
+
cameraKeys: readonly string[],
|
|
341
|
+
options: MountedCameraFrameSequencePlanOptions = {}
|
|
342
|
+
): MountedCameraFrameSequencePlan {
|
|
343
|
+
return createMountedCameraFrameSequencePlan(cameraKeys, {
|
|
344
|
+
...options,
|
|
345
|
+
cameras: api.getCameras(),
|
|
346
|
+
sites: api.getSites(),
|
|
347
|
+
bodies: api.getBodies(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function recordMountedCameraFrameSequence(
|
|
352
|
+
api: MountedCameraFrameSequenceRecorderTarget,
|
|
353
|
+
options: MountedCameraFrameSequenceRecordOptions
|
|
354
|
+
): Promise<MountedCameraFrameSequenceRecordResult> {
|
|
355
|
+
const { cameraKeys, ...restOptions } = options;
|
|
356
|
+
const requireAll =
|
|
357
|
+
restOptions.requireAll ?? restOptions.requireMountedSources ?? true;
|
|
358
|
+
const plan = createMountedCameraFrameSequencePlanFromApi(
|
|
359
|
+
api,
|
|
360
|
+
cameraKeys,
|
|
361
|
+
{ ...restOptions, requireAll }
|
|
362
|
+
);
|
|
363
|
+
const readiness = createMountedCameraFrameSequenceReadiness(plan);
|
|
364
|
+
const result = await api.recordCameraSequence({
|
|
365
|
+
...restOptions,
|
|
366
|
+
cameras: plan.cameras,
|
|
367
|
+
requireMountedSources: restOptions.requireMountedSources ?? true,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
...result,
|
|
372
|
+
plan,
|
|
373
|
+
readiness,
|
|
374
|
+
};
|
|
375
|
+
}
|
package/src/spark.tsx
CHANGED
|
@@ -15,9 +15,14 @@ import * as THREE from 'three';
|
|
|
15
15
|
import {
|
|
16
16
|
SplatEnvironment,
|
|
17
17
|
useSplatEnvironment,
|
|
18
|
+
useSplatSceneConfig,
|
|
18
19
|
} from './components/VisualScenario';
|
|
19
20
|
import type {
|
|
21
|
+
PairedSplatEnvironmentConfig,
|
|
22
|
+
SceneConfig,
|
|
20
23
|
SplatEnvironmentProps,
|
|
24
|
+
SplatEnvironmentReadiness,
|
|
25
|
+
VisualScenarioConfig,
|
|
21
26
|
} from './types';
|
|
22
27
|
|
|
23
28
|
type SparkModule = typeof import('@sparkjsdev/spark');
|
|
@@ -26,9 +31,22 @@ type SparkSplatMeshInstance = InstanceType<SparkModule['SplatMesh']>;
|
|
|
26
31
|
type SparkDisposable = {
|
|
27
32
|
dispose?: () => unknown;
|
|
28
33
|
};
|
|
34
|
+
type SparkWorkerMessage = {
|
|
35
|
+
reject?: (error: unknown) => void;
|
|
36
|
+
};
|
|
37
|
+
type SparkWorkerLike = {
|
|
38
|
+
messages?: Record<string, SparkWorkerMessage>;
|
|
39
|
+
};
|
|
40
|
+
type SparkResourceWithWorkers = SparkDisposable & {
|
|
41
|
+
worker?: SparkWorkerLike;
|
|
42
|
+
sortWorker?: SparkWorkerLike;
|
|
43
|
+
lodWorker?: SparkWorkerLike;
|
|
44
|
+
};
|
|
29
45
|
|
|
30
46
|
export type SparkSplatStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
31
47
|
|
|
48
|
+
let sparkDisposeRejectionHandlerRegistered = false;
|
|
49
|
+
|
|
32
50
|
export interface SparkSplatLifecycle {
|
|
33
51
|
status: SparkSplatStatus;
|
|
34
52
|
error: Error | null;
|
|
@@ -39,6 +57,18 @@ export interface SparkSplatLifecycle {
|
|
|
39
57
|
reset: () => void;
|
|
40
58
|
}
|
|
41
59
|
|
|
60
|
+
export interface SparkSplatEnvironmentState {
|
|
61
|
+
environment: PairedSplatEnvironmentConfig | undefined;
|
|
62
|
+
sceneConfig: SceneConfig;
|
|
63
|
+
readiness: SplatEnvironmentReadiness;
|
|
64
|
+
lifecycle: SparkSplatLifecycle;
|
|
65
|
+
props: Pick<
|
|
66
|
+
SparkSplatEnvironmentProps,
|
|
67
|
+
'environment' | 'scenario' | 'src' | 'format' | 'onStatusChange' | 'onError'
|
|
68
|
+
>;
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
|
|
43
73
|
/** Enable Spark LoD handling for large splat assets. Default: true. */
|
|
44
74
|
lod?: boolean | 'quality';
|
|
@@ -53,6 +83,67 @@ export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
|
|
|
53
83
|
onError?: (error: Error) => void;
|
|
54
84
|
}
|
|
55
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a visual scenario's paired splat environment, compose its MJCF
|
|
88
|
+
* collision proxy into the MuJoCo scene config, and expose Spark lifecycle
|
|
89
|
+
* props for `<SparkSplatEnvironment />`.
|
|
90
|
+
*/
|
|
91
|
+
export function useSparkSplatEnvironment({
|
|
92
|
+
sceneConfig,
|
|
93
|
+
scenario,
|
|
94
|
+
environment,
|
|
95
|
+
enabled = true,
|
|
96
|
+
renderer = 'spark',
|
|
97
|
+
onError,
|
|
98
|
+
onStatusChange,
|
|
99
|
+
}: {
|
|
100
|
+
sceneConfig: SceneConfig;
|
|
101
|
+
scenario?: VisualScenarioConfig;
|
|
102
|
+
environment?: PairedSplatEnvironmentConfig;
|
|
103
|
+
enabled?: boolean;
|
|
104
|
+
renderer?: 'spark';
|
|
105
|
+
onError?: (error: Error) => void;
|
|
106
|
+
onStatusChange?: (status: SparkSplatStatus) => void;
|
|
107
|
+
}): SparkSplatEnvironmentState {
|
|
108
|
+
const splatScene = useSplatSceneConfig({
|
|
109
|
+
sceneConfig,
|
|
110
|
+
scenario,
|
|
111
|
+
environment,
|
|
112
|
+
enabled,
|
|
113
|
+
renderer,
|
|
114
|
+
});
|
|
115
|
+
const metadata = useSplatEnvironment({
|
|
116
|
+
scenario,
|
|
117
|
+
environment: splatScene.environment,
|
|
118
|
+
renderer,
|
|
119
|
+
});
|
|
120
|
+
const renderEnabled = enabled && Boolean(metadata.src);
|
|
121
|
+
const readiness = enabled ? metadata.readiness : splatScene.readiness;
|
|
122
|
+
const lifecycle = useSparkSplatLifecycle({
|
|
123
|
+
enabled: renderEnabled,
|
|
124
|
+
onError,
|
|
125
|
+
onStatusChange,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return useMemo(
|
|
129
|
+
() => ({
|
|
130
|
+
environment: splatScene.environment,
|
|
131
|
+
sceneConfig: splatScene.sceneConfig,
|
|
132
|
+
readiness,
|
|
133
|
+
lifecycle,
|
|
134
|
+
props: {
|
|
135
|
+
environment: splatScene.environment,
|
|
136
|
+
scenario: enabled ? scenario : undefined,
|
|
137
|
+
src: enabled ? metadata.src : undefined,
|
|
138
|
+
format: metadata.format,
|
|
139
|
+
...lifecycle.props,
|
|
140
|
+
},
|
|
141
|
+
enabled: renderEnabled,
|
|
142
|
+
}),
|
|
143
|
+
[enabled, lifecycle, metadata, readiness, renderEnabled, scenario, splatScene]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
56
147
|
/**
|
|
57
148
|
* Tracks Spark 3DGS loading state for UI that wraps `SparkSplatEnvironment`.
|
|
58
149
|
*
|
|
@@ -177,6 +268,7 @@ export function SparkSplatEnvironment({
|
|
|
177
268
|
|
|
178
269
|
useEffect(() => {
|
|
179
270
|
let disposed = false;
|
|
271
|
+
ensureSparkDisposeRejectionHandler();
|
|
180
272
|
|
|
181
273
|
function setLifecycleStatus(nextStatus: SparkSplatStatus) {
|
|
182
274
|
setStatus(nextStatus);
|
|
@@ -306,6 +398,7 @@ export function SparkSplatEnvironment({
|
|
|
306
398
|
|
|
307
399
|
function safelyDisposeSparkResource(resource: SparkDisposable) {
|
|
308
400
|
try {
|
|
401
|
+
silenceSparkWorkerTerminateRejections(resource);
|
|
309
402
|
const result = resource.dispose?.();
|
|
310
403
|
if (isPromiseLike(result)) {
|
|
311
404
|
void Promise.resolve(result).catch(handleSparkDisposeError);
|
|
@@ -324,6 +417,33 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
|
324
417
|
);
|
|
325
418
|
}
|
|
326
419
|
|
|
420
|
+
function silenceSparkWorkerTerminateRejections(resource: SparkDisposable) {
|
|
421
|
+
const workers = getSparkWorkers(resource);
|
|
422
|
+
for (const worker of workers) {
|
|
423
|
+
if (!worker.messages) continue;
|
|
424
|
+
|
|
425
|
+
for (const message of Object.values(worker.messages)) {
|
|
426
|
+
const reject = message.reject;
|
|
427
|
+
if (!reject) continue;
|
|
428
|
+
|
|
429
|
+
message.reject = (error: unknown) => {
|
|
430
|
+
if (!isSparkWorkerTerminateError(error)) {
|
|
431
|
+
reject(error);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function getSparkWorkers(resource: SparkDisposable): SparkWorkerLike[] {
|
|
439
|
+
const sparkResource = resource as SparkResourceWithWorkers;
|
|
440
|
+
return [
|
|
441
|
+
sparkResource.worker,
|
|
442
|
+
sparkResource.sortWorker,
|
|
443
|
+
sparkResource.lodWorker,
|
|
444
|
+
].filter((worker): worker is SparkWorkerLike => Boolean(worker));
|
|
445
|
+
}
|
|
446
|
+
|
|
327
447
|
function handleSparkDisposeError(error: unknown) {
|
|
328
448
|
if (
|
|
329
449
|
error instanceof Error &&
|
|
@@ -334,3 +454,27 @@ function handleSparkDisposeError(error: unknown) {
|
|
|
334
454
|
|
|
335
455
|
console.warn('[mujoco-react] Spark resource disposal failed.', error);
|
|
336
456
|
}
|
|
457
|
+
|
|
458
|
+
function ensureSparkDisposeRejectionHandler() {
|
|
459
|
+
if (
|
|
460
|
+
sparkDisposeRejectionHandlerRegistered ||
|
|
461
|
+
typeof window === 'undefined' ||
|
|
462
|
+
typeof window.addEventListener !== 'function'
|
|
463
|
+
) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
sparkDisposeRejectionHandlerRegistered = true;
|
|
468
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
469
|
+
if (isSparkWorkerTerminateError(event.reason)) {
|
|
470
|
+
event.preventDefault();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isSparkWorkerTerminateError(reason: unknown) {
|
|
476
|
+
return (
|
|
477
|
+
reason instanceof Error &&
|
|
478
|
+
reason.message.toLowerCase().includes('worker terminate')
|
|
479
|
+
);
|
|
480
|
+
}
|