mujoco-react 10.9.0 → 11.0.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.
@@ -10,6 +10,7 @@ export interface GenericIKOptions {
10
10
  maxIterations: number;
11
11
  damping: number;
12
12
  tolerance: number;
13
+ /** @deprecated Analytic Jacobians do not require perturbations. */
13
14
  epsilon: number;
14
15
  posWeight: number;
15
16
  rotWeight: number;
@@ -44,207 +45,213 @@ function resolveOptions(opts?: Partial<GenericIKOptions>): ResolvedGenericIKOpti
44
45
  };
45
46
  }
46
47
 
47
- /**
48
- * Generic Damped Least-Squares IK solver.
49
- * Uses finite-difference Jacobian via MuJoCo's mj_forward.
50
- * Works for any MuJoCo model — no robot-specific parameters.
51
- */
48
+ /** Analytic damped least-squares IK in MuJoCo velocity coordinates. */
52
49
  export class GenericIK {
53
- private mujoco: MujocoModule;
54
-
55
- constructor(mujoco: MujocoModule) {
56
- this.mujoco = mujoco;
57
- }
50
+ constructor(private mujoco: MujocoModule) {}
58
51
 
59
- /**
60
- * Solve IK for a target 6-DOF pose.
61
- * @param model MuJoCo model
62
- * @param data MuJoCo data (qpos will be temporarily modified, then restored)
63
- * @param siteId Index of the end-effector site to control
64
- * @param qposAdr qpos addresses for scalar joints in solve order
65
- * @param targetPos Target position in world frame
66
- * @param targetQuat Target orientation in world frame
67
- * @param currentQ Current joint angles matching qposAdr order
68
- * @param opts Optional solver parameters
69
- * @returns Joint angles array, or null if solver diverged
70
- */
52
+ /** Scalar hinge/slide interface. Returns values in qposAdr order. */
71
53
  solve(
72
54
  model: MujocoModel,
73
55
  data: MujocoData,
74
56
  siteId: number,
75
57
  qposAdr: ArrayLike<number>,
76
- targetPos: THREE.Vector3,
77
- targetQuat: THREE.Quaternion,
58
+ targetPos: Pick<THREE.Vector3, 'x' | 'y' | 'z'>,
59
+ targetQuat: Pick<THREE.Quaternion, 'x' | 'y' | 'z' | 'w'>,
78
60
  currentQ: ArrayLike<number>,
79
- opts?: Partial<GenericIKOptions>
61
+ opts?: Partial<GenericIKOptions>,
80
62
  ): number[] | null {
81
- const o = resolveOptions(opts);
82
- const n = qposAdr.length;
83
-
84
- // Save full qpos so we can restore after solving
85
- const savedQpos = new Float64Array(data.qpos.length);
86
- savedQpos.set(data.qpos);
87
-
88
- // Build target rotation matrix (3x3 row-major)
89
- const R_target = quatToMat3(targetQuat);
90
-
91
- // Working joint angles — start from current configuration
92
- const q = new Float64Array(n);
93
- for (let i = 0; i < n; i++) q[i] = currentQ[i];
94
-
95
- // Pre-allocate work arrays
96
- const J = new Float64Array(6 * n); // 6×n Jacobian (row-major)
97
- const JJt = new Float64Array(36); // 6×6
98
- const rhs = new Float64Array(6); // right-hand side
99
- const x = new Float64Array(6); // solve result
100
- const dq = new Float64Array(n); // joint update
101
- const baseSitePos = new Float64Array(3);
102
- const baseSiteMat = new Float64Array(9);
103
- const pertSitePos = new Float64Array(3);
104
- const pertSiteMat = new Float64Array(9);
105
-
106
- let bestQ: number[] | null = null;
107
- let bestErr = Infinity;
108
- // Stop early once the error stops improving. The solver always returns the
109
- // best configuration it has seen, so when it stalls or diverges (an
110
- // unreachable target, e.g. a near-singular wrist orientation) further
111
- // iterations can't beat bestQ — they only burn (1 + n) mj_forward calls
112
- // each and feed jitter into ctrl. This is what bounds the cost of dragging
113
- // the target into an unreachable region.
114
- const patience = 4;
115
- let noImprove = 0;
116
-
117
- if (n === 0) return null;
118
-
119
- for (let iter = 0; iter < o.maxIterations; iter++) {
120
- // Set joints and run FK
121
- for (let i = 0; i < n; i++) data.qpos[qposAdr[i]] = q[i];
122
- this.mujoco.mj_forward(model, data);
123
-
124
- // Read current site pose
125
- const sp = data.site_xpos;
126
- const sm = data.site_xmat;
127
- const off3 = siteId * 3;
128
- const off9 = siteId * 9;
129
- for (let i = 0; i < 3; i++) baseSitePos[i] = sp[off3 + i];
130
- for (let i = 0; i < 9; i++) baseSiteMat[i] = sm[off9 + i];
131
-
132
- // Compute 6D error
133
- const posErr0 = targetPos.x - baseSitePos[0];
134
- const posErr1 = targetPos.y - baseSitePos[1];
135
- const posErr2 = targetPos.z - baseSitePos[2];
136
- const rotErr = orientationError(baseSiteMat, R_target);
137
-
138
- const error = [
139
- posErr0 * o.posWeight,
140
- posErr1 * o.posWeight,
141
- posErr2 * o.posWeight,
142
- rotErr[0] * o.rotWeight,
143
- rotErr[1] * o.rotWeight,
144
- rotErr[2] * o.rotWeight,
145
- ];
146
-
147
- const errNorm = Math.sqrt(
148
- error[0] * error[0] + error[1] * error[1] + error[2] * error[2] +
149
- error[3] * error[3] + error[4] * error[4] + error[5] * error[5]
63
+ if (currentQ.length !== qposAdr.length) throw new Error('IK currentQ must match qposAdr length');
64
+ const jointIds = Array.from(qposAdr, (adr) => {
65
+ const id = Array.from({ length: model.njnt }, (_, i) => i).find(
66
+ (i) => model.jnt_qposadr[i] === adr,
150
67
  );
151
-
152
- // Track best solution
153
- if (errNorm < bestErr - 1e-9) {
154
- bestErr = errNorm;
155
- bestQ = Array.from(q);
156
- noImprove = 0;
157
- } else {
158
- noImprove++;
68
+ if (id === undefined || model.jnt_type[id] < 2) {
69
+ throw new Error(
70
+ 'Scalar IK requires hinge/slide qpos addresses; use solveJoints for ball/free joints',
71
+ );
159
72
  }
73
+ return id;
74
+ });
75
+ const initial = new Float64Array(data.qpos);
76
+ for (let i = 0; i < qposAdr.length; i++) initial[qposAdr[i]] = currentQ[i];
77
+ const result = this.solveJoints(model, data, siteId, jointIds, targetPos, targetQuat, initial, opts);
78
+ return result ? Array.from(qposAdr, (adr) => result[adr]) : null;
79
+ }
160
80
 
161
- // Converged
162
- if (errNorm < o.tolerance) break;
163
-
164
- // Stalled or diverging — bestQ can no longer improve, so stop.
165
- if (noImprove >= patience) break;
166
-
167
- // Compute Jacobian via finite differences
168
- for (let j = 0; j < n; j++) {
169
- const adr = qposAdr[j];
170
- const saved = data.qpos[adr];
171
- data.qpos[adr] = q[j] + o.epsilon;
81
+ /**
82
+ * Solve selected joint IDs, including ball/free joints. Initial and returned
83
+ * configurations have model.nq entries, with MuJoCo wxyz quaternions.
84
+ * Jacobian columns use nv/dof addresses; mj_integratePos updates quaternions.
85
+ * The caller's qpos is restored even if the solve throws.
86
+ */
87
+ solveJoints(
88
+ model: MujocoModel,
89
+ data: MujocoData,
90
+ siteId: number,
91
+ jointIds: ArrayLike<number>,
92
+ targetPos: Pick<THREE.Vector3, 'x' | 'y' | 'z'>,
93
+ targetQuat: Pick<THREE.Quaternion, 'x' | 'y' | 'z' | 'w'>,
94
+ currentQpos: ArrayLike<number> = data.qpos,
95
+ opts?: Partial<GenericIKOptions>,
96
+ ): number[] | null {
97
+ const o = resolveOptions(opts);
98
+ const targetRotation = new THREE.Quaternion(targetQuat.x, targetQuat.y, targetQuat.z, targetQuat.w);
99
+ if (!Number.isInteger(siteId) || siteId < 0 || siteId >= model.nsite)
100
+ throw new Error('Invalid IK site ID');
101
+ if (currentQpos.length !== model.nq || !Array.from(currentQpos).every(Number.isFinite))
102
+ throw new Error('IK configuration must contain nq finite values');
103
+ if (
104
+ ![
105
+ targetPos.x,
106
+ targetPos.y,
107
+ targetPos.z,
108
+ targetQuat.x,
109
+ targetQuat.y,
110
+ targetQuat.z,
111
+ targetQuat.w,
112
+ ].every(Number.isFinite) ||
113
+ targetRotation.lengthSq() === 0
114
+ )
115
+ throw new Error('IK target must be finite with a nonzero quaternion');
116
+ if (
117
+ !(o.damping > 0) ||
118
+ !(o.maxStepRad > 0) ||
119
+ !Number.isFinite(o.maxStepRad) ||
120
+ !Number.isInteger(o.maxIterations) ||
121
+ o.maxIterations < 1
122
+ )
123
+ throw new Error('Invalid IK solver options');
124
+ const ids = Array.from(jointIds);
125
+ if (
126
+ new Set(ids).size !== ids.length ||
127
+ ids.some((id) => !Number.isInteger(id) || id < 0 || id >= model.njnt)
128
+ )
129
+ throw new Error('IK joint IDs must be unique and valid');
130
+ const dofs = ids.flatMap((id) =>
131
+ Array.from(
132
+ { length: model.jnt_type[id] === 0 ? 6 : model.jnt_type[id] === 1 ? 3 : 1 },
133
+ (_, i) => model.jnt_dofadr[id] + i,
134
+ ),
135
+ );
136
+ const n = dofs.length;
137
+ if (!n) return null;
138
+ const savedQpos = new Float64Array(data.qpos);
139
+ const buffers: { delete(): void }[] = [];
140
+ try {
141
+ const alloc = (size: number) => {
142
+ const b = new this.mujoco.DoubleBuffer(size);
143
+ buffers.push(b);
144
+ return b;
145
+ };
146
+ const jacp = alloc(3 * model.nv),
147
+ jacr = alloc(3 * model.nv),
148
+ qbuffer = alloc(model.nq);
149
+ qbuffer.GetView().set(currentQpos);
150
+ const target = quatToMat3(targetRotation.normalize());
151
+ const J = new Float64Array(6 * n),
152
+ JJt = new Float64Array(36),
153
+ rhs = new Float64Array(6),
154
+ x = new Float64Array(6);
155
+ const velocity = new Array<number>(model.nv).fill(0);
156
+ let best: number[] | null = null,
157
+ bestErr = Infinity,
158
+ noImprove = 0;
159
+ const clamp = () => {
160
+ const q = qbuffer.GetView();
161
+ ids.forEach((id, i) => {
162
+ const adr = model.jnt_qposadr[id],
163
+ type = model.jnt_type[id];
164
+ if (type >= 2) {
165
+ const limits =
166
+ o.jointLimits?.[i] ??
167
+ (model.jnt_limited[id]
168
+ ? [model.jnt_range[2 * id], model.jnt_range[2 * id + 1]]
169
+ : null);
170
+ if (limits)
171
+ q[adr] = Math.max(Math.min(...limits), Math.min(Math.max(...limits), q[adr]));
172
+ } else {
173
+ const offset = adr + (type === 0 ? 3 : 0);
174
+ const quat = new THREE.Quaternion(
175
+ q[offset + 1],
176
+ q[offset + 2],
177
+ q[offset + 3],
178
+ q[offset],
179
+ ).normalize();
180
+ if (type === 1 && model.jnt_limited[id]) {
181
+ if (quat.w < 0) {
182
+ quat.x *= -1;
183
+ quat.y *= -1;
184
+ quat.z *= -1;
185
+ quat.w *= -1;
186
+ }
187
+ const angle = 2 * Math.acos(Math.min(1, Math.max(-1, quat.w)));
188
+ const limit = model.jnt_range[2 * id + 1];
189
+ if (angle > limit)
190
+ quat.identity().slerp(
191
+ new THREE.Quaternion(
192
+ q[offset + 1],
193
+ q[offset + 2],
194
+ q[offset + 3],
195
+ q[offset],
196
+ ).normalize(),
197
+ limit / angle,
198
+ );
199
+ }
200
+ q.set([quat.w, quat.x, quat.y, quat.z], offset);
201
+ }
202
+ });
203
+ };
204
+ clamp();
205
+ for (let iter = 0; iter < o.maxIterations; iter++) {
206
+ data.qpos.set(qbuffer.GetView());
172
207
  this.mujoco.mj_forward(model, data);
173
-
174
- for (let i = 0; i < 3; i++) pertSitePos[i] = sp[off3 + i];
175
- for (let i = 0; i < 9; i++) pertSiteMat[i] = sm[off9 + i];
176
-
177
- // Position Jacobian columns (rows 0-2)
178
- J[0 * n + j] = ((pertSitePos[0] - baseSitePos[0]) / o.epsilon) * o.posWeight;
179
- J[1 * n + j] = ((pertSitePos[1] - baseSitePos[1]) / o.epsilon) * o.posWeight;
180
- J[2 * n + j] = ((pertSitePos[2] - baseSitePos[2]) / o.epsilon) * o.posWeight;
181
-
182
- // Orientation Jacobian columns (rows 3-5)
183
- // δR = R_perturbed * R_base^T, then extract angular velocity
184
- const dRot = angularDelta(baseSiteMat, pertSiteMat);
185
- J[3 * n + j] = (dRot[0] / o.epsilon) * o.rotWeight;
186
- J[4 * n + j] = (dRot[1] / o.epsilon) * o.rotWeight;
187
- J[5 * n + j] = (dRot[2] / o.epsilon) * o.rotWeight;
188
-
189
- // Restore joint
190
- data.qpos[adr] = saved;
191
- }
192
-
193
- // Restore base FK state for next iteration
194
- for (let i = 0; i < n; i++) data.qpos[qposAdr[i]] = q[i];
195
-
196
- // Damped least squares: Δq = Jᵀ (J Jᵀ + λI)⁻¹ error
197
- // 1. Compute JJᵀ (6×6)
198
- for (let r = 0; r < 6; r++) {
199
- for (let c = 0; c < 6; c++) {
200
- let sum = 0;
201
- for (let k = 0; k < n; k++) {
202
- sum += J[r * n + k] * J[c * n + k];
208
+ const pos = data.site_xpos.subarray(3 * siteId, 3 * siteId + 3);
209
+ const rot = orientationError(data.site_xmat.subarray(9 * siteId, 9 * siteId + 9), target);
210
+ const error = [
211
+ (targetPos.x - pos[0]) * o.posWeight,
212
+ (targetPos.y - pos[1]) * o.posWeight,
213
+ (targetPos.z - pos[2]) * o.posWeight,
214
+ ...rot.map((v) => v * o.rotWeight),
215
+ ];
216
+ const norm = Math.hypot(...error);
217
+ if (!Number.isFinite(norm)) break;
218
+ if (norm < bestErr - 1e-9) {
219
+ bestErr = norm;
220
+ best = Array.from(qbuffer.GetView());
221
+ noImprove = 0;
222
+ } else noImprove++;
223
+ if (norm < o.tolerance || noImprove >= 4) break;
224
+ this.mujoco.mj_jacSite(model, data, jacp, jacr, siteId);
225
+ const jp = jacp.GetView(),
226
+ jr = jacr.GetView();
227
+ for (let r = 0; r < 3; r++)
228
+ for (let j = 0; j < n; j++) {
229
+ J[r * n + j] = jp[r * model.nv + dofs[j]] * o.posWeight;
230
+ J[(r + 3) * n + j] = jr[r * model.nv + dofs[j]] * o.rotWeight;
203
231
  }
204
- JJt[r * 6 + c] = sum + (r === c ? o.damping : 0);
205
- }
206
- }
207
-
208
- // 2. Solve (JJᵀ + λI) x = error
209
- for (let i = 0; i < 6; i++) rhs[i] = error[i];
210
- solve6x6(JJt, rhs, x);
211
-
212
- // 3. Δq = Jᵀ x
213
- for (let j = 0; j < n; j++) {
214
- let sum = 0;
215
- for (let r = 0; r < 6; r++) {
216
- sum += J[r * n + j] * x[r];
217
- }
218
- dq[j] = sum;
219
- }
220
-
221
- // Update joints: bound each step, then clamp to joint limits.
222
- // Clamping inside the loop pins a saturated joint at its limit so
223
- // subsequent iterations redistribute the remaining error to the
224
- // unclamped joints (gradient projection), instead of returning an
225
- // out-of-range solution for the caller to distort with a post-hoc
226
- // per-joint clamp.
227
- for (let i = 0; i < n; i++) {
228
- let step = dq[i];
229
- if (step > o.maxStepRad) step = o.maxStepRad;
230
- else if (step < -o.maxStepRad) step = -o.maxStepRad;
231
- let next = q[i] + step;
232
- const limits = o.jointLimits?.[i];
233
- if (limits) {
234
- const low = Math.min(limits[0], limits[1]);
235
- const high = Math.max(limits[0], limits[1]);
236
- if (next < low) next = low;
237
- else if (next > high) next = high;
232
+ for (let r = 0; r < 6; r++)
233
+ for (let c = 0; c < 6; c++) {
234
+ let sum = 0;
235
+ for (let k = 0; k < n; k++) sum += J[r * n + k] * J[c * n + k];
236
+ JJt[r * 6 + c] = sum + (r === c ? o.damping : 0);
237
+ }
238
+ rhs.set(error);
239
+ solve6x6(JJt, rhs, x);
240
+ velocity.fill(0);
241
+ for (let j = 0; j < n; j++) {
242
+ let step = 0;
243
+ for (let r = 0; r < 6; r++) step += J[r * n + j] * x[r];
244
+ velocity[dofs[j]] = Math.max(-o.maxStepRad, Math.min(o.maxStepRad, step));
238
245
  }
239
- q[i] = next;
246
+ this.mujoco.mj_integratePos(model, qbuffer, velocity, 1);
247
+ clamp();
240
248
  }
249
+ return best;
250
+ } finally {
251
+ for (const buffer of buffers) buffer.delete();
252
+ data.qpos.set(savedQpos);
253
+ this.mujoco.mj_forward(model, data);
241
254
  }
242
-
243
- // Restore original qpos
244
- data.qpos.set(savedQpos);
245
- this.mujoco.mj_forward(model, data);
246
-
247
- return bestQ;
248
255
  }
249
256
  }
250
257
 
@@ -253,13 +260,28 @@ export class GenericIK {
253
260
  /** Convert THREE.Quaternion to 3x3 rotation matrix (row-major Float64Array) */
254
261
  function quatToMat3(q: THREE.Quaternion): Float64Array {
255
262
  const m = new Float64Array(9);
256
- const x = q.x, y = q.y, z = q.z, w = q.w;
257
- const xx = x * x, yy = y * y, zz = z * z;
258
- const xy = x * y, xz = x * z, yz = y * z;
259
- const wx = w * x, wy = w * y, wz = w * z;
260
- m[0] = 1 - 2 * (yy + zz); m[1] = 2 * (xy - wz); m[2] = 2 * (xz + wy);
261
- m[3] = 2 * (xy + wz); m[4] = 1 - 2 * (xx + zz); m[5] = 2 * (yz - wx);
262
- m[6] = 2 * (xz - wy); m[7] = 2 * (yz + wx); m[8] = 1 - 2 * (xx + yy);
263
+ const x = q.x,
264
+ y = q.y,
265
+ z = q.z,
266
+ w = q.w;
267
+ const xx = x * x,
268
+ yy = y * y,
269
+ zz = z * z;
270
+ const xy = x * y,
271
+ xz = x * z,
272
+ yz = y * z;
273
+ const wx = w * x,
274
+ wy = w * y,
275
+ wz = w * z;
276
+ m[0] = 1 - 2 * (yy + zz);
277
+ m[1] = 2 * (xy - wz);
278
+ m[2] = 2 * (xz + wy);
279
+ m[3] = 2 * (xy + wz);
280
+ m[4] = 1 - 2 * (xx + zz);
281
+ m[5] = 2 * (yz - wx);
282
+ m[6] = 2 * (xz - wy);
283
+ m[7] = 2 * (yz + wx);
284
+ m[8] = 1 - 2 * (xx + yy);
263
285
  return m;
264
286
  }
265
287
 
@@ -294,48 +316,34 @@ function orientationError(R_cur: Float64Array, R_tgt: Float64Array): [number, nu
294
316
  return [0, 0, 0];
295
317
  }
296
318
 
297
- // Near π — degenerate, use small-angle approx of the skew-symmetric part
298
- if (angle > Math.PI - 1e-6) {
299
- return [
300
- 0.5 * (Re[7] - Re[5]),
301
- 0.5 * (Re[2] - Re[6]),
302
- 0.5 * (Re[3] - Re[1]),
303
- ];
319
+ // Quaternion extraction is well-conditioned at pi, where the skew vanishes.
320
+ if (angle > Math.PI - 1e-4) {
321
+ const matrix = new THREE.Matrix4().set(
322
+ Re[0],
323
+ Re[1],
324
+ Re[2],
325
+ 0,
326
+ Re[3],
327
+ Re[4],
328
+ Re[5],
329
+ 0,
330
+ Re[6],
331
+ Re[7],
332
+ Re[8],
333
+ 0,
334
+ 0,
335
+ 0,
336
+ 0,
337
+ 1,
338
+ );
339
+ const q = new THREE.Quaternion().setFromRotationMatrix(matrix).normalize();
340
+ const scale = (angle / Math.hypot(q.x, q.y, q.z)) * (q.w < 0 ? -1 : 1);
341
+ return [q.x * scale, q.y * scale, q.z * scale];
304
342
  }
305
343
 
306
344
  // General case: axis = skew(R_err) / (2 sin(angle)), scaled by angle
307
345
  const s = angle / (2 * Math.sin(angle));
308
- return [
309
- s * (Re[7] - Re[5]),
310
- s * (Re[2] - Re[6]),
311
- s * (Re[3] - Re[1]),
312
- ];
313
- }
314
-
315
- /**
316
- * Compute angular velocity vector from R_base to R_perturbed.
317
- * Returns the axis-angle of R_perturbed * R_base^T.
318
- * (Small angle: the rotation caused by the perturbation.)
319
- */
320
- function angularDelta(R_base: Float64Array, R_pert: Float64Array): [number, number, number] {
321
- // δR = R_pert * R_base^T
322
- // Small angle approx: ω ≈ 0.5 * [δR[7]-δR[5], δR[2]-δR[6], δR[3]-δR[1]]
323
- // This is fine because the perturbation epsilon is tiny.
324
- const dR = new Float64Array(9);
325
- for (let i = 0; i < 3; i++) {
326
- for (let j = 0; j < 3; j++) {
327
- let s = 0;
328
- for (let k = 0; k < 3; k++) {
329
- s += R_pert[i * 3 + k] * R_base[j * 3 + k];
330
- }
331
- dR[i * 3 + j] = s;
332
- }
333
- }
334
- return [
335
- 0.5 * (dR[7] - dR[5]),
336
- 0.5 * (dR[2] - dR[6]),
337
- 0.5 * (dR[3] - dR[1]),
338
- ];
346
+ return [s * (Re[7] - Re[5]), s * (Re[2] - Re[6]), s * (Re[3] - Re[1])];
339
347
  }
340
348
 
341
349
  /**
@@ -355,15 +363,22 @@ function solve6x6(A: Float64Array, b: Float64Array, x: Float64Array): void {
355
363
  let maxRow = col;
356
364
  for (let row = col + 1; row < N; row++) {
357
365
  const val = Math.abs(a[row * N + col]);
358
- if (val > maxVal) { maxVal = val; maxRow = row; }
366
+ if (val > maxVal) {
367
+ maxVal = val;
368
+ maxRow = row;
369
+ }
359
370
  }
360
371
 
361
372
  // Swap rows
362
373
  if (maxRow !== col) {
363
374
  for (let k = 0; k < N; k++) {
364
- const tmp = a[col * N + k]; a[col * N + k] = a[maxRow * N + k]; a[maxRow * N + k] = tmp;
375
+ const tmp = a[col * N + k];
376
+ a[col * N + k] = a[maxRow * N + k];
377
+ a[maxRow * N + k] = tmp;
365
378
  }
366
- const tmp = r[col]; r[col] = r[maxRow]; r[maxRow] = tmp;
379
+ const tmp = r[col];
380
+ r[col] = r[maxRow];
381
+ r[maxRow] = tmp;
367
382
  }
368
383
 
369
384
  const pivot = a[col * N + col];
@@ -27,6 +27,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
27
27
  // Declarative physics config
28
28
  gravity,
29
29
  timestep,
30
+ integrator,
30
31
  substeps,
31
32
  paused,
32
33
  speed,
@@ -71,6 +72,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
71
72
  onSelection={onSelection}
72
73
  gravity={gravity}
73
74
  timestep={timestep}
75
+ integrator={integrator}
74
76
  substeps={substeps}
75
77
  paused={paused}
76
78
  speed={speed}
@@ -8,6 +8,7 @@ import { useMujocoWasm } from './MujocoProvider';
8
8
  import { MujocoSimProvider } from './MujocoSimProvider';
9
9
  import type {
10
10
  MujocoSimAPI,
11
+ MujocoIntegrator,
11
12
  ReadyCallbackInput,
12
13
  SceneConfig,
13
14
  SelectionCallbackInput,
@@ -29,6 +30,8 @@ export interface MujocoPhysicsProps {
29
30
  gravity?: [number, number, number];
30
31
  /** Override model.opt.timestep. */
31
32
  timestep?: number;
33
+ /** Override the integrator; omit to preserve the model setting. */
34
+ integrator?: MujocoIntegrator;
32
35
  /** mj_step calls per frame. */
33
36
  substeps?: number;
34
37
  /** Declarative pause. */
@@ -3,6 +3,8 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ import { configureModuleThreads } from './engineState';
7
+
6
8
  import loadMujoco from '@mujoco/mujoco';
7
9
  import defaultMujocoWasmUrl from '@mujoco/mujoco/mujoco.wasm?url';
8
10
  import { createContext, useContext, useEffect, useRef, useState } from 'react';
@@ -46,6 +48,8 @@ export interface MujocoProviderProps {
46
48
  * uses threaded only when both conditions are satisfied.
47
49
  */
48
50
  wasmVariant?: MujocoWasmVariant;
51
+ /** Physics worker count. Requires the threaded build; defaults to 0 (no pool). */
52
+ threadCount?: number;
49
53
  /** Timeout in ms for WASM module load. Default: 30000. */
50
54
  timeout?: number;
51
55
  children: React.ReactNode;
@@ -88,6 +92,7 @@ export function MujocoProvider({
88
92
  mtWasmUrl,
89
93
  threadedLoader,
90
94
  wasmVariant = 'single',
95
+ threadCount = 0,
91
96
  timeout = 30000,
92
97
  children,
93
98
  onError,
@@ -95,14 +100,20 @@ export function MujocoProvider({
95
100
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
96
101
  const [error, setError] = useState<string | null>(null);
97
102
  const moduleRef = useRef<MujocoModule | null>(null);
98
- const isMounted = useRef(true);
99
103
  const onErrorRef = useRef(onError);
100
104
  onErrorRef.current = onError;
101
105
 
102
106
  useEffect(() => {
103
- isMounted.current = true;
107
+ let disposed = false;
108
+ moduleRef.current = null;
109
+ setStatus('loading');
110
+ setError(null);
104
111
 
105
112
  const variant = resolveWasmVariant(wasmVariant, threadedLoader, mtWasmUrl);
113
+ if (!Number.isInteger(threadCount) || threadCount < 0 || (threadCount > 0 && variant !== 'threaded') || (variant === 'threaded' && !canUseThreadedWasm())) {
114
+ const err = new Error('threadCount must be a nonnegative integer; threaded physics requires the MT loader and cross-origin isolation');
115
+ setError(err.message); setStatus('error'); onErrorRef.current?.(err); return;
116
+ }
106
117
  if (variant === 'threaded' && !threadedLoader) {
107
118
  const err = new Error('MujocoProvider wasmVariant="threaded" requires a threadedLoader from @mujoco/mujoco/mt');
108
119
  setError(err.message);
@@ -128,40 +139,43 @@ export function MujocoProvider({
128
139
  const wasmPromise = load({
129
140
  locateFile: (path: string) => path.endsWith('.wasm') ? selectedWasmUrl : path,
130
141
  printErr: (text: string) => {
131
- if (text.includes('Aborted') && isMounted.current) {
142
+ if (text.includes('Aborted') && !disposed) {
132
143
  setError('Simulation crashed. Reload page.');
133
144
  setStatus('error');
134
145
  }
135
146
  },
136
147
  });
137
148
 
149
+ let timeoutId: ReturnType<typeof setTimeout>;
138
150
  const timeoutPromise = new Promise<never>((_, reject) =>
139
- setTimeout(() => reject(new Error(`WASM module load timed out after ${timeout}ms`)), timeout)
151
+ timeoutId = setTimeout(() => reject(new Error(`WASM module load timed out after ${timeout}ms`)), timeout)
140
152
  );
141
153
 
142
154
  Promise.race([wasmPromise, timeoutPromise])
143
155
  .then((inst: unknown) => {
144
- if (isMounted.current) {
156
+ if (!disposed) {
145
157
  if (!isMujocoModule(inst)) {
146
158
  throw new Error('MuJoCo WASM module initialized with an unexpected shape');
147
159
  }
160
+ configureModuleThreads(inst, threadCount);
148
161
  moduleRef.current = inst;
149
162
  setStatus('ready');
150
163
  }
151
164
  })
152
165
  .catch((err: Error) => {
153
- if (isMounted.current) {
166
+ if (!disposed) {
154
167
  const msg = err.message || 'Failed to init spatial simulation';
155
168
  setError(msg);
156
169
  setStatus('error');
157
170
  onErrorRef.current?.(new Error(msg));
158
171
  }
159
- });
172
+ }).finally(() => clearTimeout(timeoutId));
160
173
 
161
174
  return () => {
162
- isMounted.current = false;
175
+ disposed = true;
176
+ clearTimeout(timeoutId);
163
177
  };
164
- }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout]);
178
+ }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, threadCount, timeout]);
165
179
 
166
180
  return (
167
181
  <MujocoContext.Provider