/** * @license * SPDX-License-Identifier: Apache-2.0 */ import { useMemo, useRef } from 'react'; /** Shallow-compare two plain objects by own enumerable keys. */ function shallowEqual(a: Record, b: Record): boolean { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (a[key] !== b[key]) return false; } return true; } export interface ControllerOptions { /** Unique name for this controller (used as displayName). */ name: string; /** Default values merged under user-supplied config. */ defaultConfig?: Partial; } export type ControllerComponent = React.FC<{ config?: Partial; children?: React.ReactNode; }> & { controllerName: string; defaultConfig: Partial; }; /** * Factory that produces a typed controller component. * * Controllers are React components that plug into the MuJoCo simulation tree. * Inside `Impl`, use any hooks (`useMujoco`, `useBeforePhysicsStep`, etc.) * to interact with the physics engine. * * @example * ```tsx * const MyController = createController<{ speed: number }>( * { name: 'my-controller', defaultConfig: { speed: 1.0 } }, * function MyControllerImpl({ config }) { * const wheel = useCtrl(ModelActuators.mobile.leftWheel); * * useBeforePhysicsStep(({ data }) => { * wheel.write(config.speed); * }); * return null; * }, * ); * * // Usage: * * ``` */ export function createController( options: ControllerOptions, Impl: React.FC<{ config: TConfig; children?: React.ReactNode }>, ): ControllerComponent { function Controller({ config, children, }: { config?: Partial; children?: React.ReactNode; }) { // Stabilise config reference: inline objects get a new identity each render, // but the actual values rarely change. Shallow-compare to keep the same ref. const configObj = (config ?? {}) as Record; const stableRef = useRef(configObj); if (!shallowEqual(stableRef.current, configObj)) { stableRef.current = configObj; } const stableConfig = stableRef.current as Partial; const mergedConfig = useMemo( () => ({ ...options.defaultConfig, ...stableConfig }) as TConfig, [stableConfig], ); return {children}; } Controller.displayName = options.name; Controller.controllerName = options.name; Controller.defaultConfig = options.defaultConfig ?? ({} as Partial); return Controller as ControllerComponent; } /** * Factory that produces a typed controller hook. * * Same config stabilization and default merging as `createController`, * but returns a hook instead of a component. Pass `null` to disable. * * @example * ```tsx * const useMyController = createControllerHook( * { name: 'useMyController', defaultConfig: { gain: 1.0 } }, * function useMyControllerImpl(config) { * // config is MyConfig | null — hooks must be called unconditionally * const joint = useCtrl(config?.actuator ?? ModelActuators.franka.actuator1); * * useBeforePhysicsStep(({ data }) => { * if (!config) return; * joint.write(config.gain * Math.sin(data.time)); * }); * if (!config) return null; * return { /* value *\/ }; * }, * ); * * // Usage: * const value = useMyController({ gain: 2.0 }); * const disabled = useMyController(null); // returns null * ``` */ export function createControllerHook( options: ControllerOptions, useImpl: (config: TConfig | null) => TValue | null, ): (config: TConfig | null) => TValue | null { const useController = (config: TConfig | null): TValue | null => { const configObj = config as Record | null; const stableRef = useRef(configObj); if (configObj && stableRef.current) { if (!shallowEqual(stableRef.current, configObj)) { stableRef.current = configObj; } } else { stableRef.current = configObj; } const mergedConfig = useMemo( () => stableRef.current ? ({ ...options.defaultConfig, ...stableRef.current } as TConfig) : null, [stableRef.current], ); return useImpl(mergedConfig); }; return useController; }