mujoco-react 10.2.1 → 10.4.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/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { withContacts, getContact, captureCameraFrame, captureCameraFrameBlob, createCameraFrameCaptureSession, CAMERA_FRAME_CAPTURE_PRE_RENDER_USER_DATA_KEY, CAPTURE_EXCLUDE_KEY } from './chunk-CYDGWNKQ.js';
2
- export { CAMERA_FRAME_CAPTURE_PRE_RENDER_USER_DATA_KEY, CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY, CAPTURE_EXCLUDE_KEY, ModelActuators, ModelBodies, ModelCameras, ModelGeoms, ModelJoints, ModelKeyframes, ModelResources, ModelSensors, ModelSites, ScenarioLighting, SplatEnvironment, SplatEnvironmentReadinessStatus, VisualScenarioEffects, captureCameraFrame, captureCameraFrameBlob, createCameraFrameCaptureSession, createPairedSplatEnvironment, createSparkSplatViewerUrl, createSplatEnvironmentUserData, createSplatSceneConfig, createVisualScenarioExecutionContext, getContact, getScenarioBackground, getScenarioCameraPosition, getSplatEnvironmentReadiness, registerModelResources, renderCameraFrameToCanvas, useSplatEnvironment, useSplatSceneConfig, useVisualScenarioEffects, useVisualScenarioExecutionContext, withSplatEnvironment } from './chunk-CYDGWNKQ.js';
1
+ import { withContacts, getContact, captureCameraFrame, captureCameraFrameBlob, createCameraFrameCaptureSession, CAMERA_FRAME_CAPTURE_PRE_RENDER_USER_DATA_KEY, CAPTURE_EXCLUDE_KEY } from './chunk-FBXXXPLQ.js';
2
+ export { CAMERA_FRAME_CAPTURE_PRE_RENDER_USER_DATA_KEY, CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY, CAPTURE_EXCLUDE_KEY, ModelActuators, ModelBodies, ModelCameras, ModelGeoms, ModelJoints, ModelKeyframes, ModelResources, ModelSensors, ModelSites, ScenarioLighting, SplatEnvironment, SplatEnvironmentReadinessStatus, VisualScenarioEffects, captureCameraFrame, captureCameraFrameBlob, createCameraFrameCaptureSession, createPairedSplatEnvironment, createSparkSplatViewerUrl, createSplatEnvironmentUserData, createSplatSceneConfig, createVisualScenarioExecutionContext, getContact, getScenarioBackground, getScenarioCameraPosition, getSplatEnvironmentReadiness, registerModelResources, renderCameraFrameToCanvas, useSplatEnvironment, useSplatSceneConfig, useVisualScenarioEffects, useVisualScenarioExecutionContext, withSplatEnvironment } from './chunk-FBXXXPLQ.js';
3
3
  import loadMujoco from '@mujoco/mujoco';
4
4
  import defaultMujocoWasmUrl from '@mujoco/mujoco/mujoco.wasm?url';
5
- import { createContext, forwardRef, useEffect, useContext, useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
5
+ import { createContext, forwardRef, useRef, useEffect, useContext, useState, useCallback, useMemo, useLayoutEffect } from 'react';
6
6
  import { jsx, jsxs } from 'react/jsx-runtime';
7
7
  import { Canvas, useThree, useFrame } from '@react-three/fiber';
8
8
  import * as THREE11 from 'three';
9
+ import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
9
10
  import { PivotControls } from '@react-three/drei';
10
11
 
11
12
  var MujocoContext = createContext({
@@ -43,6 +44,8 @@ function MujocoProvider({
43
44
  const [error, setError] = useState(null);
44
45
  const moduleRef = useRef(null);
45
46
  const isMounted = useRef(true);
47
+ const onErrorRef = useRef(onError);
48
+ onErrorRef.current = onError;
46
49
  useEffect(() => {
47
50
  isMounted.current = true;
48
51
  const variant = resolveWasmVariant(wasmVariant, threadedLoader, mtWasmUrl);
@@ -50,7 +53,7 @@ function MujocoProvider({
50
53
  const err = new Error('MujocoProvider wasmVariant="threaded" requires a threadedLoader from @mujoco/mujoco/mt');
51
54
  setError(err.message);
52
55
  setStatus("error");
53
- onError?.(err);
56
+ onErrorRef.current?.(err);
54
57
  return;
55
58
  }
56
59
  let selectedWasmUrl = wasmUrl ?? defaultMujocoWasmUrl;
@@ -59,7 +62,7 @@ function MujocoProvider({
59
62
  const err = new Error('MujocoProvider wasmVariant="threaded" requires mtWasmUrl from @mujoco/mujoco/mt/mujoco.wasm?url');
60
63
  setError(err.message);
61
64
  setStatus("error");
62
- onError?.(err);
65
+ onErrorRef.current?.(err);
63
66
  return;
64
67
  }
65
68
  selectedWasmUrl = mtWasmUrl;
@@ -90,13 +93,13 @@ function MujocoProvider({
90
93
  const msg = err.message || "Failed to init spatial simulation";
91
94
  setError(msg);
92
95
  setStatus("error");
93
- onError?.(new Error(msg));
96
+ onErrorRef.current?.(new Error(msg));
94
97
  }
95
98
  });
96
99
  return () => {
97
100
  isMounted.current = false;
98
101
  };
99
- }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout, onError]);
102
+ }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout]);
100
103
  return /* @__PURE__ */ jsx(
101
104
  MujocoContext.Provider,
102
105
  {
@@ -891,11 +894,20 @@ function collectDependencyPaths(xmlString, currentFile, parser) {
891
894
  }
892
895
 
893
896
  // src/rendering/GeomBuilder.ts
897
+ var DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE = 1e-4;
894
898
  var GeomBuilder = class {
895
899
  mujoco;
896
900
  textureCache = /* @__PURE__ */ new Map();
897
- constructor(mujoco) {
901
+ renderOptions;
902
+ constructor(mujoco, renderOptions) {
898
903
  this.mujoco = mujoco;
904
+ this.renderOptions = renderOptions;
905
+ }
906
+ getMeshNormalSmoothingTolerance() {
907
+ const smoothing = this.renderOptions?.meshNormalSmoothing;
908
+ if (!smoothing) return null;
909
+ if (smoothing === true) return DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE;
910
+ return smoothing.tolerance ?? DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE;
899
911
  }
900
912
  getMaterialTexture(mjModel, matId) {
901
913
  if (matId < 0 || !mjModel.mat_texid || !mjModel.tex_data) return null;
@@ -990,6 +1002,10 @@ var GeomBuilder = class {
990
1002
  geo = new THREE11.BufferGeometry();
991
1003
  geo.setAttribute("position", new THREE11.Float32BufferAttribute(mjModel.mesh_vert.subarray(vAdr * 3, (vAdr + vNum) * 3), 3));
992
1004
  geo.setIndex(Array.from(mjModel.mesh_face.subarray(fAdr * 3, (fAdr + fNum) * 3)));
1005
+ const smoothingTolerance = this.getMeshNormalSmoothingTolerance();
1006
+ if (smoothingTolerance !== null) {
1007
+ geo = mergeVertices(geo, smoothingTolerance);
1008
+ }
993
1009
  geo.computeVertexNormals();
994
1010
  }
995
1011
  if (geo) {
@@ -1020,7 +1036,13 @@ var GeomBuilder = class {
1020
1036
  return null;
1021
1037
  }
1022
1038
  };
1023
- function SceneRenderer(props) {
1039
+ function getRenderOptionsKey(renderOptions) {
1040
+ const smoothing = renderOptions?.meshNormalSmoothing;
1041
+ if (!smoothing) return "default";
1042
+ if (smoothing === true) return "meshNormalSmoothing:true";
1043
+ return `meshNormalSmoothing:${smoothing.tolerance ?? "default"}`;
1044
+ }
1045
+ function SceneRenderer({ renderOptions, ...props }) {
1024
1046
  const {
1025
1047
  mjModelRef,
1026
1048
  mjDataRef,
@@ -1034,17 +1056,22 @@ function SceneRenderer(props) {
1034
1056
  const groupRef = useRef(null);
1035
1057
  const bodyRefs = useRef([]);
1036
1058
  const prevModelRef = useRef(null);
1059
+ const prevRenderOptionsKeyRef = useRef(null);
1060
+ const renderOptionsKey = getRenderOptionsKey(renderOptions);
1037
1061
  const geomBuilder = useMemo(() => {
1038
1062
  if (status !== "ready") return null;
1039
- return new GeomBuilder(mujocoRef.current);
1040
- }, [status, mujocoRef]);
1063
+ return new GeomBuilder(mujocoRef.current, renderOptions);
1064
+ }, [status, mujocoRef, renderOptionsKey]);
1041
1065
  useEffect(() => {
1042
1066
  if (status !== "ready" || !geomBuilder) return;
1043
1067
  const model = mjModelRef.current;
1044
1068
  const group = groupRef.current;
1045
1069
  if (!model || !group) return;
1046
- if (prevModelRef.current === model) return;
1070
+ if (prevModelRef.current === model && prevRenderOptionsKeyRef.current === renderOptionsKey) {
1071
+ return;
1072
+ }
1047
1073
  prevModelRef.current = model;
1074
+ prevRenderOptionsKeyRef.current = renderOptionsKey;
1048
1075
  while (group.children.length > 0) {
1049
1076
  group.remove(group.children[0]);
1050
1077
  }
@@ -1065,7 +1092,7 @@ function SceneRenderer(props) {
1065
1092
  refs.push(bodyGroup);
1066
1093
  }
1067
1094
  bodyRefs.current = refs;
1068
- }, [status, geomBuilder, mjModelRef]);
1095
+ }, [status, geomBuilder, mjModelRef, renderOptionsKey]);
1069
1096
  const syncBodiesToData = useCallback(() => {
1070
1097
  const data = mjDataRef.current;
1071
1098
  if (!data) return;
@@ -1930,6 +1957,106 @@ function applyMountedCameraPoseOffsets(options, position, quaternion) {
1930
1957
  ]
1931
1958
  };
1932
1959
  }
1960
+ function resolveMujocoCameraCompatibilityOptions(options) {
1961
+ const compatibility = options.mujocoCameraCompatibility;
1962
+ if (!compatibility) return null;
1963
+ if (compatibility === true) {
1964
+ return {
1965
+ useResolution: true,
1966
+ useIntrinsics: true,
1967
+ useClipping: true,
1968
+ preserveAspect: true,
1969
+ preferResolution: false
1970
+ };
1971
+ }
1972
+ return {
1973
+ useResolution: compatibility.useResolution ?? true,
1974
+ useIntrinsics: compatibility.useIntrinsics ?? true,
1975
+ useClipping: compatibility.useClipping ?? true,
1976
+ preserveAspect: compatibility.preserveAspect ?? true,
1977
+ preferResolution: compatibility.preferResolution ?? false
1978
+ };
1979
+ }
1980
+ function mujocoVisualClip(model) {
1981
+ const map = model.vis?.map;
1982
+ const near = typeof map?.znear === "number" && map.znear > 0 ? map.znear : void 0;
1983
+ const far = typeof map?.zfar === "number" && map.zfar > 0 ? map.zfar : void 0;
1984
+ return { near, far };
1985
+ }
1986
+ function mujocoCameraResolution(model, cameraId) {
1987
+ const resolution = model.cam_resolution;
1988
+ if (!resolution) return {};
1989
+ const width = Number(resolution[cameraId * 2]);
1990
+ const height = Number(resolution[cameraId * 2 + 1]);
1991
+ return {
1992
+ width: Number.isFinite(width) && width > 0 ? width : void 0,
1993
+ height: Number.isFinite(height) && height > 0 ? height : void 0
1994
+ };
1995
+ }
1996
+ function mujocoCameraProjectionMatrix(model, cameraId, width, height, near, far) {
1997
+ const intrinsic = model.cam_intrinsic;
1998
+ const sensorSize = model.cam_sensorsize;
1999
+ if (!intrinsic || !sensorSize || !width || !height) return void 0;
2000
+ const intrinsicOffset = cameraId * 4;
2001
+ const sensorOffset = cameraId * 2;
2002
+ const focalX = Number(intrinsic[intrinsicOffset]);
2003
+ const focalY = Number(intrinsic[intrinsicOffset + 1]);
2004
+ const principalX = Number(intrinsic[intrinsicOffset + 2]);
2005
+ const principalY = Number(intrinsic[intrinsicOffset + 3]);
2006
+ const sensorWidth = Number(sensorSize[sensorOffset]);
2007
+ const sensorHeight = Number(sensorSize[sensorOffset + 1]);
2008
+ if (!Number.isFinite(focalX) || !Number.isFinite(focalY) || !Number.isFinite(principalX) || !Number.isFinite(principalY) || !Number.isFinite(sensorWidth) || !Number.isFinite(sensorHeight) || focalX <= 0 || focalY <= 0 || sensorWidth <= 0 || sensorHeight <= 0) {
2009
+ return void 0;
2010
+ }
2011
+ const fx = focalX / sensorWidth * width;
2012
+ const fy = focalY / sensorHeight * height;
2013
+ const cx = width * (0.5 + principalX / sensorWidth);
2014
+ const cy = height * (0.5 + principalY / sensorHeight);
2015
+ const znear = near ?? 0.01;
2016
+ const zfar = far ?? 100;
2017
+ return new THREE11.Matrix4().set(
2018
+ 2 * fx / width,
2019
+ 0,
2020
+ 1 - 2 * cx / width,
2021
+ 0,
2022
+ 0,
2023
+ 2 * fy / height,
2024
+ 2 * cy / height - 1,
2025
+ 0,
2026
+ 0,
2027
+ 0,
2028
+ -(zfar + znear) / (zfar - znear),
2029
+ -2 * zfar * znear / (zfar - znear),
2030
+ 0,
2031
+ 0,
2032
+ -1,
2033
+ 0
2034
+ );
2035
+ }
2036
+ function resolveMujocoCameraCaptureDimensions(requested, cameraResolution, compatibility) {
2037
+ if (!compatibility.useResolution) {
2038
+ return {
2039
+ width: requested.width,
2040
+ height: requested.height
2041
+ };
2042
+ }
2043
+ if (compatibility.preferResolution) {
2044
+ return {
2045
+ width: cameraResolution.width ?? requested.width,
2046
+ height: cameraResolution.height ?? requested.height
2047
+ };
2048
+ }
2049
+ let width = requested.width ?? cameraResolution.width;
2050
+ let height = requested.height ?? cameraResolution.height;
2051
+ if (compatibility.preserveAspect && cameraResolution.width && cameraResolution.height) {
2052
+ if (requested.width !== void 0 && requested.height === void 0) {
2053
+ height = requested.width * cameraResolution.height / cameraResolution.width;
2054
+ } else if (requested.height !== void 0 && requested.width === void 0) {
2055
+ width = requested.height * cameraResolution.width / cameraResolution.height;
2056
+ }
2057
+ }
2058
+ return { width, height };
2059
+ }
1933
2060
  function countMountedCameraSelectors(options) {
1934
2061
  return Number(Boolean(options.cameraName)) + Number(Boolean(options.siteName)) + Number(Boolean(options.bodyName));
1935
2062
  }
@@ -2033,6 +2160,7 @@ function MujocoSimProvider({
2033
2160
  paused,
2034
2161
  speed,
2035
2162
  interpolate,
2163
+ renderOptions,
2036
2164
  children
2037
2165
  }) {
2038
2166
  const { gl, camera, scene } = useThree();
@@ -2067,24 +2195,12 @@ function MujocoSimProvider({
2067
2195
  const bodyRegistryRef = useRef(/* @__PURE__ */ new Map());
2068
2196
  const hiddenBodiesRef = useRef(/* @__PURE__ */ new Set());
2069
2197
  const bodyReloadTimerRef = useRef(null);
2070
- useEffect(() => {
2071
- configRef.current = config;
2072
- }, [config]);
2073
- useEffect(() => {
2074
- mujocoRef.current = mujoco;
2075
- }, [mujoco]);
2076
- useEffect(() => {
2077
- pausedRef.current = paused ?? false;
2078
- }, [paused]);
2079
- useEffect(() => {
2080
- speedRef.current = speed ?? 1;
2081
- }, [speed]);
2082
- useEffect(() => {
2083
- substepsRef.current = substeps ?? 1;
2084
- }, [substeps]);
2085
- useEffect(() => {
2086
- interpolateRef.current = interpolate ?? false;
2087
- }, [interpolate]);
2198
+ configRef.current = config;
2199
+ mujocoRef.current = mujoco;
2200
+ pausedRef.current = paused ?? false;
2201
+ speedRef.current = speed ?? 1;
2202
+ substepsRef.current = substeps ?? 1;
2203
+ interpolateRef.current = interpolate ?? false;
2088
2204
  useEffect(() => {
2089
2205
  if (!gravity) return;
2090
2206
  const model = mjModelRef.current;
@@ -2590,11 +2706,27 @@ function MujocoSimProvider({
2590
2706
  for (let i = 0; i < ncam; i += 1) {
2591
2707
  const posOffset = i * 3;
2592
2708
  const quatOffset = i * 4;
2709
+ const intrinsicOffset = i * 4;
2710
+ const resolutionOffset = i * 2;
2593
2711
  result.push({
2594
2712
  id: i,
2595
2713
  name: getName(model, nameAddresses[i]),
2596
2714
  bodyId: model.cam_bodyid?.[i] ?? -1,
2597
2715
  fov: model.cam_fovy?.[i] ?? null,
2716
+ resolution: model.cam_resolution ? [
2717
+ model.cam_resolution[resolutionOffset],
2718
+ model.cam_resolution[resolutionOffset + 1]
2719
+ ] : null,
2720
+ sensorSize: model.cam_sensorsize ? [
2721
+ model.cam_sensorsize[resolutionOffset],
2722
+ model.cam_sensorsize[resolutionOffset + 1]
2723
+ ] : null,
2724
+ intrinsic: model.cam_intrinsic ? [
2725
+ model.cam_intrinsic[intrinsicOffset],
2726
+ model.cam_intrinsic[intrinsicOffset + 1],
2727
+ model.cam_intrinsic[intrinsicOffset + 2],
2728
+ model.cam_intrinsic[intrinsicOffset + 3]
2729
+ ] : null,
2598
2730
  position: model.cam_pos ? vector3FromArray(model.cam_pos, posOffset) : null,
2599
2731
  quaternion: model.cam_quat ? quaternionFromMujocoQuat(model.cam_quat, quatOffset) : null
2600
2732
  });
@@ -2622,10 +2754,22 @@ function MujocoSimProvider({
2622
2754
  );
2623
2755
  }
2624
2756
  const pose = applyMountedCameraPoseOffsets(options, position, quaternion);
2757
+ const compatibility = resolveMujocoCameraCompatibilityOptions(options);
2758
+ const cameraResolution = compatibility?.useResolution ? mujocoCameraResolution(model, cameraId) : { width: void 0, height: void 0 };
2759
+ const clip = compatibility?.useClipping ? mujocoVisualClip(model) : { near: void 0, far: void 0 };
2760
+ const { width, height } = compatibility ? resolveMujocoCameraCaptureDimensions(options, cameraResolution, compatibility) : { width: options.width, height: options.height };
2761
+ const near = options.near ?? clip.near;
2762
+ const far = options.far ?? clip.far;
2763
+ const projectionMatrix = compatibility?.useIntrinsics ? mujocoCameraProjectionMatrix(model, cameraId, width, height, near, far) : void 0;
2625
2764
  return {
2626
2765
  ...baseOptions,
2766
+ width,
2767
+ height,
2627
2768
  ...pose,
2628
2769
  fov: options.fov ?? model.cam_fovy?.[cameraId],
2770
+ near,
2771
+ far,
2772
+ projectionMatrix: options.projectionMatrix ?? projectionMatrix,
2629
2773
  source: { kind: "mujoco-camera", cameraName: options.cameraName }
2630
2774
  };
2631
2775
  }
@@ -3271,7 +3415,7 @@ function MujocoSimProvider({
3271
3415
  [api, status, requestBodyReload]
3272
3416
  );
3273
3417
  return /* @__PURE__ */ jsxs(MujocoSimContext.Provider, { value: contextValue, children: [
3274
- /* @__PURE__ */ jsx(SceneRenderer, {}),
3418
+ /* @__PURE__ */ jsx(SceneRenderer, { renderOptions }),
3275
3419
  children
3276
3420
  ] });
3277
3421
  }
@@ -3289,16 +3433,19 @@ var MujocoCanvas = forwardRef(
3289
3433
  paused,
3290
3434
  speed,
3291
3435
  interpolate,
3436
+ renderOptions,
3292
3437
  loadingFallback,
3293
3438
  children,
3294
3439
  ...canvasProps
3295
3440
  }, ref) {
3296
3441
  const { mujoco, status: wasmStatus, error: wasmError } = useMujocoWasm();
3442
+ const onErrorRef = useRef(onError);
3443
+ onErrorRef.current = onError;
3297
3444
  useEffect(() => {
3298
- if (wasmStatus === "error" && onError) {
3299
- onError(new Error(wasmError ?? "WASM load failed"));
3445
+ if (wasmStatus === "error") {
3446
+ onErrorRef.current?.(new Error(wasmError ?? "WASM load failed"));
3300
3447
  }
3301
- }, [wasmStatus, wasmError, onError]);
3448
+ }, [wasmStatus, wasmError]);
3302
3449
  if (wasmStatus === "loading" || !mujoco) {
3303
3450
  return loadingFallback ? /* @__PURE__ */ jsx(Canvas, { ...canvasProps, children: loadingFallback }) : null;
3304
3451
  }
@@ -3321,6 +3468,7 @@ var MujocoCanvas = forwardRef(
3321
3468
  paused,
3322
3469
  speed,
3323
3470
  interpolate,
3471
+ renderOptions,
3324
3472
  children
3325
3473
  }
3326
3474
  ) });
@@ -3329,11 +3477,13 @@ var MujocoCanvas = forwardRef(
3329
3477
  var MujocoPhysics = forwardRef(
3330
3478
  function MujocoPhysics2({ onError, children, ...props }, ref) {
3331
3479
  const { mujoco, status: wasmStatus, error: wasmError } = useMujocoWasm();
3480
+ const onErrorRef = useRef(onError);
3481
+ onErrorRef.current = onError;
3332
3482
  useEffect(() => {
3333
- if (wasmStatus === "error" && onError) {
3334
- onError(new Error(wasmError ?? "WASM load failed"));
3483
+ if (wasmStatus === "error") {
3484
+ onErrorRef.current?.(new Error(wasmError ?? "WASM load failed"));
3335
3485
  }
3336
- }, [wasmStatus, wasmError, onError]);
3486
+ }, [wasmStatus, wasmError]);
3337
3487
  if (wasmStatus === "error" || wasmStatus === "loading" || !mujoco) {
3338
3488
  return null;
3339
3489
  }
@@ -4717,11 +4867,161 @@ var _contactNormal = new THREE11.Vector3();
4717
4867
  var MAX_CONTACT_ARROWS = 50;
4718
4868
  var CAMERA_DEBUG_LENGTH = 0.12;
4719
4869
  var CAMERA_DEBUG_FRUSTUM_DEPTH = 0.08;
4870
+ function toVector32(value, fallback) {
4871
+ if (!value) return fallback.clone();
4872
+ return value instanceof THREE11.Vector3 ? value.clone() : new THREE11.Vector3(value[0], value[1], value[2]);
4873
+ }
4874
+ function createCameraLabel(text, color) {
4875
+ const canvas = document.createElement("canvas");
4876
+ canvas.width = 256;
4877
+ canvas.height = 64;
4878
+ const ctx = canvas.getContext("2d");
4879
+ ctx.fillStyle = new THREE11.Color(color).getStyle();
4880
+ ctx.font = "bold 32px monospace";
4881
+ ctx.textAlign = "center";
4882
+ ctx.fillText(text, 128, 42);
4883
+ const texture = new THREE11.CanvasTexture(canvas);
4884
+ const sprite = new THREE11.Sprite(
4885
+ new THREE11.SpriteMaterial({
4886
+ map: texture,
4887
+ depthTest: false,
4888
+ transparent: true
4889
+ })
4890
+ );
4891
+ sprite.position.set(0, 0.014, 0.01);
4892
+ sprite.scale.set(0.05, 0.012, 1);
4893
+ sprite.renderOrder = 999;
4894
+ return sprite;
4895
+ }
4896
+ function createVirtualCameraDebugObject(camera, index) {
4897
+ const color = camera.color ?? "#ff3d71";
4898
+ const aimColor = camera.aimColor ?? "#38bdf8";
4899
+ const markerScale = camera.markerScale ?? 1;
4900
+ const cameraPosition = toVector32(camera.position, new THREE11.Vector3());
4901
+ const configuredUp = toVector32(camera.up, new THREE11.Vector3(0, 0, 1)).normalize();
4902
+ const cameraQuaternion = new THREE11.Quaternion();
4903
+ const forward = new THREE11.Vector3();
4904
+ if (camera.quaternion) {
4905
+ if (camera.quaternion instanceof THREE11.Quaternion) {
4906
+ cameraQuaternion.copy(camera.quaternion);
4907
+ } else {
4908
+ cameraQuaternion.set(
4909
+ camera.quaternion[0],
4910
+ camera.quaternion[1],
4911
+ camera.quaternion[2],
4912
+ camera.quaternion[3]
4913
+ );
4914
+ }
4915
+ forward.set(0, 0, -1).applyQuaternion(cameraQuaternion).normalize();
4916
+ } else {
4917
+ const target2 = toVector32(
4918
+ camera.lookAt,
4919
+ cameraPosition.clone().add(new THREE11.Vector3(0, 0, -1))
4920
+ );
4921
+ forward.copy(target2).sub(cameraPosition);
4922
+ if (forward.lengthSq() < 1e-8) forward.set(0, 0, -1);
4923
+ forward.normalize();
4924
+ cameraQuaternion.setFromRotationMatrix(
4925
+ new THREE11.Matrix4().lookAt(cameraPosition, target2, configuredUp)
4926
+ );
4927
+ }
4928
+ const target = camera.lookAt ? toVector32(camera.lookAt, cameraPosition.clone().add(forward)) : cameraPosition.clone().addScaledVector(forward, 0.4);
4929
+ const distanceToTarget = Math.max(target.distanceTo(cameraPosition), 1e-3);
4930
+ const depth = camera.frustumDepth ?? Math.min(Math.max(distanceToTarget * 0.42, 0.16), 0.45);
4931
+ const fov = camera.fov ?? 50;
4932
+ const aspect = (camera.width ?? 640) / (camera.height ?? 480);
4933
+ const right = forward.clone().cross(configuredUp);
4934
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
4935
+ right.normalize();
4936
+ const orthogonalUp = right.clone().cross(forward).normalize();
4937
+ const frustumHeight = 2 * Math.tan(THREE11.MathUtils.degToRad(fov) / 2) * depth;
4938
+ const frustumWidth = frustumHeight * aspect;
4939
+ const center = cameraPosition.clone().addScaledVector(forward, depth);
4940
+ const halfRight = right.clone().multiplyScalar(frustumWidth / 2);
4941
+ const halfUp = orthogonalUp.clone().multiplyScalar(frustumHeight / 2);
4942
+ const topLeft = center.clone().sub(halfRight).add(halfUp);
4943
+ const topRight = center.clone().add(halfRight).add(halfUp);
4944
+ const bottomRight = center.clone().add(halfRight).sub(halfUp);
4945
+ const bottomLeft = center.clone().sub(halfRight).sub(halfUp);
4946
+ const frustumPoints = [
4947
+ cameraPosition,
4948
+ topLeft,
4949
+ cameraPosition,
4950
+ topRight,
4951
+ cameraPosition,
4952
+ bottomRight,
4953
+ cameraPosition,
4954
+ bottomLeft,
4955
+ topLeft,
4956
+ topRight,
4957
+ topRight,
4958
+ bottomRight,
4959
+ bottomRight,
4960
+ bottomLeft,
4961
+ bottomLeft,
4962
+ topLeft
4963
+ ];
4964
+ const group = new THREE11.Group();
4965
+ group.name = camera.name ?? `virtual-camera-${index}`;
4966
+ group.renderOrder = 999;
4967
+ group.frustumCulled = false;
4968
+ const frustum = new THREE11.LineSegments(
4969
+ new THREE11.BufferGeometry().setFromPoints(frustumPoints),
4970
+ new THREE11.LineBasicMaterial({
4971
+ color,
4972
+ transparent: true,
4973
+ opacity: 0.9,
4974
+ depthTest: false
4975
+ })
4976
+ );
4977
+ frustum.renderOrder = 999;
4978
+ frustum.frustumCulled = false;
4979
+ group.add(frustum);
4980
+ const aim = new THREE11.LineSegments(
4981
+ new THREE11.BufferGeometry().setFromPoints([cameraPosition, target]),
4982
+ new THREE11.LineBasicMaterial({
4983
+ color: aimColor,
4984
+ transparent: true,
4985
+ opacity: 0.95,
4986
+ depthTest: false
4987
+ })
4988
+ );
4989
+ aim.renderOrder = 999;
4990
+ aim.frustumCulled = false;
4991
+ group.add(aim);
4992
+ const markerGroup = new THREE11.Group();
4993
+ markerGroup.position.copy(cameraPosition);
4994
+ markerGroup.quaternion.copy(cameraQuaternion);
4995
+ markerGroup.renderOrder = 999;
4996
+ markerGroup.frustumCulled = false;
4997
+ markerGroup.add(new THREE11.Mesh(
4998
+ new THREE11.BoxGeometry(0.045 * markerScale, 0.028 * markerScale, 0.022 * markerScale),
4999
+ new THREE11.MeshBasicMaterial({ color, depthTest: false })
5000
+ ));
5001
+ const lens = new THREE11.Mesh(
5002
+ new THREE11.BoxGeometry(0.025 * markerScale, 0.018 * markerScale, 0.014 * markerScale),
5003
+ new THREE11.MeshBasicMaterial({ color: aimColor, depthTest: false })
5004
+ );
5005
+ lens.position.set(0, 0, -0.021 * markerScale);
5006
+ markerGroup.add(lens);
5007
+ if (camera.name) markerGroup.add(createCameraLabel(camera.name, color));
5008
+ group.add(markerGroup);
5009
+ const targetMarker = new THREE11.Mesh(
5010
+ new THREE11.SphereGeometry(0.018 * markerScale, 16, 10),
5011
+ new THREE11.MeshBasicMaterial({ color: aimColor, depthTest: false })
5012
+ );
5013
+ targetMarker.position.copy(target);
5014
+ targetMarker.renderOrder = 999;
5015
+ targetMarker.frustumCulled = false;
5016
+ group.add(targetMarker);
5017
+ return group;
5018
+ }
4720
5019
  function Debug({
4721
5020
  showGeoms = false,
4722
5021
  showSites = false,
4723
5022
  showJoints = false,
4724
5023
  showCameras = false,
5024
+ virtualCameras = [],
4725
5025
  showContacts = false,
4726
5026
  showCOM = false,
4727
5027
  showInertia = false,
@@ -4738,6 +5038,7 @@ function Debug({
4738
5038
  const sites = [];
4739
5039
  const joints = [];
4740
5040
  const cameras = [];
5041
+ const virtualCameraObjects = [];
4741
5042
  const comMarkers = [];
4742
5043
  if (showGeoms) {
4743
5044
  for (let i = 0; i < model.ngeom; i++) {
@@ -4916,6 +5217,9 @@ function Debug({
4916
5217
  cameras.push(group);
4917
5218
  }
4918
5219
  }
5220
+ for (let i = 0; i < virtualCameras.length; i += 1) {
5221
+ virtualCameraObjects.push(createVirtualCameraDebugObject(virtualCameras[i], i));
5222
+ }
4919
5223
  if (showCOM) {
4920
5224
  for (let i = 1; i < model.nbody; i++) {
4921
5225
  const geometry = new THREE11.SphereGeometry(5e-3, 6, 6);
@@ -4925,8 +5229,8 @@ function Debug({
4925
5229
  comMarkers.push(mesh);
4926
5230
  }
4927
5231
  }
4928
- return { geoms, sites, joints, cameras, comMarkers };
4929
- }, [status, mjModelRef, showGeoms, showSites, showJoints, showCameras, showCOM]);
5232
+ return { geoms, sites, joints, cameras, virtualCameraObjects, comMarkers };
5233
+ }, [status, mjModelRef, showGeoms, showSites, showJoints, showCameras, virtualCameras, showCOM]);
4930
5234
  useEffect(() => {
4931
5235
  const group = groupRef.current;
4932
5236
  if (!group || !debugGeometry) return;
@@ -4935,6 +5239,7 @@ function Debug({
4935
5239
  ...debugGeometry.sites,
4936
5240
  ...debugGeometry.joints,
4937
5241
  ...debugGeometry.cameras,
5242
+ ...debugGeometry.virtualCameraObjects,
4938
5243
  ...debugGeometry.comMarkers
4939
5244
  ];
4940
5245
  for (const obj of allObjects) group.add(obj);