Browse documentation

useRobotArm

Animated pose for a link chain. It eases the tip toward its goal, solves seeded with the previous frame, and stops the loop once the pose settles.

motion
tip x
tip y
angles

Install

pnpm dlx shadcn@latest add https://robocn.dev/r/use-robot-arm.json

Usage

const pose = useRobotArm({
  links: [30, 24, 16],
  target: (clock) => ({ x: Math.cos(clock) * 30, y: 30 }),
  behavior: "idle",
})

pose.joints // Vec2[], shoulder to tip
pose.angles // degrees, relative to the previous segment

API

exports
PropTypeDefaultDescription
useRobotArm(options) => RobotArmPosejoints, tip, angles and whether the tip is still chasing its goal.
useEasedPoint(target, start, options?) => EasedPointThe easing half on its own, for machines with no chain to solve. Returns point, clock and moving. Set perAxis: true to move each axis independently at the configured feed rate.
robotRestTarget(root, links) => Vec2Where an arm parks when it has nothing to chase.

Source

src/hooks/use-robot-arm.ts
"use client"

import * as React from "react"

import {
  chainAngles2,
  chainReach,
  distance2,
  lerp2,
  solveChain2,
  type Bend,
  type Vec2,
} from "@/lib/robocn/kinematics"
import { prefersReducedMotion, type RobotBehavior } from "@/lib/robocn/style"

function subscribeReducedMotion(onChange: () => void) {
  if (typeof window === "undefined" || !window.matchMedia) return () => {}
  const media = window.matchMedia("(prefers-reduced-motion: reduce)")
  media.addEventListener("change", onChange)
  return () => media.removeEventListener("change", onChange)
}

function useReducedMotion() {
  return React.useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, () => false)
}

/**
 * A fixed point, or a path: given seconds since the loop started, where the
 * tool tip should be now. A function target keeps the clock running while animation is enabled, so it
 * can script a cycle without owning an animation loop. Disabled animation
 * samples the path once at phase.
 */
export type RobotTarget = Vec2 | ((clock: number) => Vec2) | null

export interface UseRobotArmOptions {
  /** Link lengths in world units, shoulder outward. */
  links: number[]
  /** Shoulder position in world units. Defaults to the origin. */
  root?: Vec2
  /** Controlled tool-tip goal. Wins over `behavior` whenever it is set. */
  target?: RobotTarget
  /** What drives the tip when no `target` is given. */
  behavior?: RobotBehavior
  bend?: Bend
  /** Tip travel speed, world units per second. */
  speed?: number
  /** Off snaps straight to the goal and renders once. */
  animate?: boolean
  /** Freeze the arm where it stands. */
  paused?: boolean
  /** Seconds of phase offset, so a row of arms does not move in lockstep. */
  phase?: number
}

export interface RobotArmPose {
  /** Shoulder to tool tip, one more entry than there are links. */
  joints: Vec2[]
  tip: Vec2
  /** Joint angles in degrees, each relative to the previous segment. */
  angles: number[]
  /** True while the tip is chasing its goal — drives tool effects. */
  moving: boolean
}

/** Where an arm parks when it has nothing to chase. */
export function robotRestTarget(root: Vec2, links: number[]): Vec2 {
  const reach = chainReach(links)
  return { x: root.x + reach * 0.42, y: root.y + reach * 0.64 }
}

function behaviorGoal(
  behavior: RobotBehavior,
  clock: number,
  root: Vec2,
  links: number[],
): Vec2 {
  const reach = chainReach(links)
  const rest = robotRestTarget(root, links)
  switch (behavior) {
    case "orbit": {
      const center = { x: root.x, y: root.y + reach * 0.6 }
      const radius = reach * 0.32
      return {
        x: center.x + Math.cos(clock * 0.9) * radius,
        y: center.y + Math.sin(clock * 0.9) * radius * 0.72,
      }
    }
    case "sweep":
      return {
        x: root.x + Math.sin(clock * 0.55) * reach * 0.66,
        y: root.y + reach * (0.46 + Math.cos(clock * 1.1) * 0.07),
      }
    case "idle":
      return {
        x: rest.x + Math.sin(clock * 0.7) * reach * 0.035,
        y: rest.y + Math.sin(clock * 0.45) * reach * 0.05,
      }
    default:
      return rest
  }
}

/** True once any joint has moved enough to be worth a re-render. */
function poseChanged(a: Vec2[], b: Vec2[]) {
  if (a.length !== b.length) return true
  for (let i = 0; i < a.length; i++) {
    if (Math.abs(a[i].x - b[i].x) > 0.008 || Math.abs(a[i].y - b[i].y) > 0.008) {
      return true
    }
  }
  return false
}

/**
 * Animated pose for a link chain. Eases the tool tip toward its goal on every
 * animation frame, solves the chain seeded with the previous frame, and stops
 * the loop entirely once a fixed target settles. Scripted behaviors keep running.
 */
export function useRobotArm({
  links,
  root = { x: 0, y: 0 },
  target = null,
  behavior = "idle",
  bend = "up",
  speed,
  animate = true,
  paused = false,
  phase = 0,
}: UseRobotArmOptions): RobotArmPose {
  const reduced = useReducedMotion()
  const linksKey = links.join(",")
  const rootKey = `${root.x},${root.y}`
  // A function target is re-read every frame, so it must not key the effect.
  // Active paths use a stable key to preserve their clock. Parked paths are
  // sampled at phase so changed captured inputs still update the stopped pose.
  const keyedTarget = typeof target === "function" && (!animate || reduced) ? target(phase) : target
  const targetKey =
    typeof keyedTarget === "function"
      ? "path"
      : keyedTarget
        ? `${keyedTarget.x},${keyedTarget.y}`
        : ""

  // The animation loop reads the latest links, root and target without being
  // torn down and rebuilt for each one; refs are written after render so the
  // loop never sees a value the render did not produce.
  const linksRef = React.useRef(links)
  const rootRef = React.useRef(root)
  const targetRef = React.useRef(target)
  React.useEffect(() => {
    linksRef.current = links
    rootRef.current = root
    targetRef.current = target
  })

  const [joints, setJoints] = React.useState<Vec2[]>(() =>
    solveChain2(
      root,
      typeof target === "function"
        ? target(phase)
        : (target ?? robotRestTarget(root, links)),
      links,
      { bend },
    ),
  )
  const jointsRef = React.useRef(joints)
  const tipRef = React.useRef<Vec2>(joints[joints.length - 1])
  const [moving, setMoving] = React.useState(false)

  React.useEffect(() => {
    if (paused) return
    const travel = speed ?? chainReach(linksRef.current) * 1.6
    const enabled = animate && !reduced
    const running = enabled && Number.isFinite(travel) && travel > 0
    let clock = phase
    let last = performance.now()
    let frame = requestAnimationFrame(function step(now) {
      const dt = Math.min(0.05, (now - last) / 1000)
      last = now
      if (running) clock += dt

      const held = targetRef.current
      const goal =
        typeof held === "function"
          ? held(clock)
          : (held ??
            behaviorGoal(behavior, clock, rootRef.current, linksRef.current))
      const distance = distance2(tipRef.current, goal)
      // Advance at the configured feed rate, keeping the solver seed close
      // enough to the next pose to preserve continuity.
      const tip =
        !enabled || distance < 0.05
          ? goal
          : running
            ? lerp2(tipRef.current, goal, Math.min(1, (travel * dt) / distance))
            : tipRef.current
      tipRef.current = tip

      const next = solveChain2(rootRef.current, tip, linksRef.current, {
        bend,
        seed: jointsRef.current,
      })
      if (poseChanged(next, jointsRef.current)) {
        jointsRef.current = next
        setJoints(next)
      }

      const driven = typeof held === "function" ||
        (!held && (behavior === "idle" || behavior === "orbit" || behavior === "sweep"))
      const chasing = running && distance2(tip, goal) > 0.05
      setMoving(chasing || (running && driven && distance > 0.008))
      if (running && (driven || chasing)) {
        frame = requestAnimationFrame(step)
      }
    })
    return () => cancelAnimationFrame(frame)
  }, [behavior, bend, speed, animate, paused, phase, linksKey, rootKey, targetKey, reduced])

  // `links` can change between renders, and the loop only catches up on the
  // next frame. Re-solve inline for that one render rather than handing back a
  // pose with the wrong number of joints.
  const resolved = React.useMemo(
    () =>
      joints.length === links.length + 1
        ? joints
        : solveChain2(
            root,
            joints[joints.length - 1] ?? robotRestTarget(root, links),
            links,
            { bend },
          ),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [joints, linksKey, rootKey, bend],
  )
  const angles = React.useMemo(() => chainAngles2(resolved), [resolved])

  return { joints: resolved, tip: resolved[resolved.length - 1], angles, moving: !paused && animate && !reduced ? moving : false }
}

export interface UseEasedPointOptions {
  /** Travel speed in world units per second. */
  speed?: number
  animate?: boolean
  paused?: boolean
  /** Seconds of phase offset. */
  phase?: number
  /**
   * Ease each axis independently, at the same rate. Cartesian machines move
   * this way — the dog-leg path is the tell — where an arm sweeps an arc.
   */
  perAxis?: boolean
}

export interface EasedPoint {
  point: Vec2
  /** Seconds since the loop started, offset by `phase`. */
  clock: number
  moving: boolean
}

/**
 * The easing half of {@link useRobotArm} on its own, for machines that have no
 * chain to solve: gantries, spindles, anything positioned directly.
 */
export function useEasedPoint(
  target: RobotTarget,
  start: Vec2,
  { speed = 60, animate = true, paused = false, phase = 0, perAxis = false }: UseEasedPointOptions = {},
): EasedPoint {
  const reduced = useReducedMotion()
  const targetRef = React.useRef(target)
  React.useEffect(() => {
    targetRef.current = target
  })
  const [state, setState] = React.useState<EasedPoint>(() => ({
    point: typeof target === "function" ? target(phase) : (target ?? start),
    clock: phase,
    moving: false,
  }))
  const pointRef = React.useRef(state.point)
  // Active paths use a stable key to preserve their clock. Parked paths are
  // sampled at phase so changed captured inputs still update the stopped pose.
  const keyedTarget = typeof target === "function" && (!animate || reduced) ? target(phase) : target
  const targetKey =
    typeof keyedTarget === "function"
      ? "path"
      : keyedTarget
        ? `${keyedTarget.x},${keyedTarget.y}`
        : ""

  React.useEffect(() => {
    if (paused) return
    const enabled = animate && !reduced
    const running = enabled && Number.isFinite(speed) && speed > 0
    let clock = phase
    let last = performance.now()
    let frame = requestAnimationFrame(function step(now) {
      const dt = Math.min(0.05, (now - last) / 1000)
      last = now
      if (running) clock += dt
      const held = targetRef.current
      const goal =
        typeof held === "function" ? held(clock) : (held ?? pointRef.current)
      const gap = distance2(pointRef.current, goal)
      const stepSize = running ? speed * dt : 0
      const advance = (value: number, goal: number) => value + Math.sign(goal - value) * Math.min(Math.abs(goal - value), stepSize)
      const next = !enabled || gap < 0.05
        ? goal
        : perAxis
          ? { x: advance(pointRef.current.x, goal.x), y: advance(pointRef.current.y, goal.y) }
          : lerp2(pointRef.current, goal, Math.min(1, stepSize / gap))
      const moved = distance2(next, pointRef.current) > 0.008
      const driven = typeof held === "function"
      const chasing = running && distance2(next, goal) > 0.05
      const moving = chasing || (running && driven && moved)
      pointRef.current = next
      // Scripted paths can animate another dimension from clock, even if x/y
      // remain fixed. Fixed points publish their final settled state as well.
      setState(current =>
        current.point.x === next.x && current.point.y === next.y &&
        current.moving === moving && (!driven || current.clock === clock)
          ? current
          : { point: next, clock, moving },
      )
      if (running && (driven || chasing)) {
        frame = requestAnimationFrame(step)
      }
    })
    return () => cancelAnimationFrame(frame)
  }, [speed, animate, paused, phase, targetKey, perAxis, reduced])

  return paused || !animate || reduced ? { ...state, moving: false } : state
}