{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "servo-motor",
  "title": "Servo motor",
  "description": "A positional servo motor with controlled horn angle, single, double, or cross horns, mounting tabs, and optional cable. Drawn from any of four camera angles.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/use-robot-motion.json",
    "https://robocn.dev/r/robot-kinematics.json"
  ],
  "files": [
    {
      "path": "src/components/ui/servo-motor.tsx",
      "content": "\"use client\"\n\n/**\n * servo-motor — a positional servo with a horn you can turn.\n *\n * The three uncontrolled behaviors are the three things a servo actually does\n * on a bench: sweep its travel, step between positions, or hunt a degree or\n * two around a setpoint. Grab the horn and it follows the pointer; let go and\n * it slews back at its own rate, which is where the servo feel comes from.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp } from \"@/lib/robocn/kinematics\"\nimport { aboutPoint, capsulePath, extrudedPath, px, resolveRobotPalette, resolveRobotSize, robotCamera, robotSurface, roundedFootprint, type RobotPaletteProps, type RobotSize, type RobotVariant, type RobotView } from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\nconst VIEW_WIDTH = 160\nconst VIEW_HEIGHT = 180\n/** The output shaft, in view units. Drags are measured from here. */\nconst HUB = { x: 80, y: 69 }\n/** Degrees per second while slewing to a new position. */\nconst SLEW_RATE = 210\n/** Positions the `step` behavior indexes through, in order. */\nconst STEPS = [-90, 0, 90, 0]\n/** The servo is drawn straight on; that is the camera it defaults to. */\nconst NATIVE_VIEW: RobotView = \"front\"\n/** How deep the can and the tabs are — a number the front never had to give. */\nconst CASE_DEEP = 15\nconst TAB_DEEP = 12\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\n\nexport type ServoHorn = \"single\" | \"double\" | \"cross\"\nexport type ServoBehavior = \"sweep\" | \"step\" | \"hunt\" | \"static\"\n\nexport interface ServoMotorProps extends Omit<React.ComponentProps<\"svg\">, \"color\">, RobotPaletteProps {\n  /** Controlled clockwise angle, clamped to -180..180 degrees. Zero points up. */\n  angle?: number\n  /** What the shaft does when `angle` is not supplied. */\n  behavior?: ServoBehavior\n  /** Where the camera stands. One servo, four projections. */\n  view?: RobotView\n  /** Cycles per second: one sweep, one pass through the step positions. */\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  phase?: number\n  /** Drag the horn around the hub, or arrow-key it. */\n  interactive?: boolean\n  onAngleChange?: (angle: number) => void\n  horn?: ServoHorn\n  showCable?: boolean\n  showScale?: boolean\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction ServoMotor({\n  angle, behavior = \"sweep\", view = NATIVE_VIEW, speed = 0.25, animate = true, paused = false, phase = 0,\n  interactive = false, onAngleChange,\n  horn = \"double\", showCable = true, showScale = true, label,\n  size = \"md\", variant = \"solid\", color, accent, metal, dark, glow, grid,\n  palette: paletteOverride, className, style, role, tabIndex, onKeyDown, onBlur, ...props\n}: ServoMotorProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const controlled = angle !== undefined\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n\n  const hold = controlled\n    ? (Number.isFinite(angle) ? clamp(angle, -180, 180) : 0)\n    : held\n  const goal = React.useCallback((clock: number) => servoGoal(behavior, clock), [behavior])\n  const motion = useRobotScalar(goal, {\n    rate: SLEW_RATE,\n    hold,\n    speed,\n    animate: animate && !controlled && behavior !== \"static\",\n    paused,\n    phase,\n  })\n  const rotation = clamp(motion.value, -180, 180)\n\n  const apply = React.useCallback((next: number) => {\n    const bounded = clamp(next, -180, 180)\n    setHeld(bounded)\n    onAngleChange?.(bounded)\n  }, [onAngleChange, setHeld])\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit) => {\n      // Aim the horn at the pointer: clockwise degrees from straight up.\n      const dx = unit.x * VIEW_WIDTH - HUB.x\n      const dy = unit.y * VIEW_HEIGHT - HUB.y\n      if (Math.hypot(dx, dy) < 4) return\n      apply((Math.atan2(dx, -dy) * 180) / Math.PI)\n    }, [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), [setHeld]),\n  })\n\n  const arms = horn === \"cross\" ? [0, 90, 180, 270] : horn === \"single\" ? [0] : [0, 180]\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n  const readout = px(rotation)\n\n  // The drawing is a front elevation of the servo, so it goes through `wall`\n  // where it already stands and comes out untouched straight on. The can, the\n  // mounting tabs, the boss and the horn all have a depth through the machine\n  // that only reads once the camera comes round.\n  const camera = robotCamera(view)\n  const offAxis = view !== NATIVE_VIEW\n  const face = aboutPoint(camera.wall(), HUB.x, HUB.y)\n  const Frame = (face ? \"g\" : React.Fragment) as React.FC<{\n    transform?: string\n    children?: React.ReactNode\n  }>\n  const frame = face ? { transform: face } : {}\n  const rise = (y: number) => HUB.y - y\n  /** A point on the machine, `deep` units toward the reader. */\n  const at = (x: number, y: number, deep = 0) =>\n    camera.project(HUB.x - x, rise(y), -deep)\n  /** A block through the machine, `deep` either side of the mid plane. */\n  const box = (x0: number, x1: number, deep: number, top: number, bottom: number) =>\n    extrudedPath(\n      roundedFootprint(Math.abs(x1 - x0) / 2, deep, 2, 4).map((point) => ({\n        x: point.x + HUB.x - (x0 + x1) / 2,\n        y: point.y,\n      })),\n      camera,\n      rise(top),\n      rise(bottom),\n    )\n  /** A cylinder standing out of the case toward the reader. */\n  const boss = (radius: number, from: number, to: number) =>\n    capsulePath(at(HUB.x, HUB.y, from), at(HUB.x, HUB.y, to), radius)\n\n  return (\n    <svg ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={`Servo motor, ${readout} degrees, ${horn} horn, ${viewNames[view] ?? viewNames.front}`}\n      aria-valuemin={interactive ? -180 : undefined}\n      aria-valuemax={interactive ? 180 : undefined}\n      aria-valuenow={interactive ? readout : undefined}\n      aria-valuetext={interactive ? `${readout} degrees` : undefined}\n      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        const delta = arrowStep(event.key, event.shiftKey ? 15 : 5, 45)\n        if (delta !== 0) apply(rotation + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(180)\n        else return\n        event.preventDefault()\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!dragging) setHeld(null)\n      }}\n      viewBox=\"0 0 160 180\" width={width} height={width * 180 / 160}\n      className={cn(\"max-w-full select-none\", interactive && \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\", dragging && \"cursor-grabbing\", className)}\n      style={{ color: palette.foreground, ...style }} data-view={view} {...props}>\n      {offAxis && <g data-solids transform={`translate(${HUB.x} ${HUB.y})`}>\n        {[47, 121].map(y => <path key={y} d={box(35, 125, TAB_DEEP, y - 7, y + 7)} {...machined} />)}\n        <path d={box(49, 111, CASE_DEEP, 35, 135)} {...shell} />\n        <path d={box(55, 105, CASE_DEEP - 2, 92, 125)} {...cast} />\n        <path d={boss(22, 0, 7)} {...cast} />\n        <path d={boss(17, 6, 11)} {...machined} />\n        <path d={boss(8, 10, 15)} {...shell} />\n      </g>}\n      <Frame {...frame}>\n      <g data-motor>\n        {showCable && <g fill=\"none\" strokeWidth={2}>\n          <path d=\"M 70 133 V 151 Q 70 160 58 160 H 25\" stroke={palette.dark} />\n          <path d=\"M 74 133 V 154 Q 74 164 58 164 H 25\" stroke={palette.shell} />\n          <path d=\"M 78 133 V 157 Q 78 168 58 168 H 25\" stroke={palette.metal} />\n          <rect x={18} y={157} width={9} height={14} rx={2} {...cast} />\n        </g>}\n        <rect x={35} y={40} width={90} height={14} rx={3} {...machined} />\n        <rect x={35} y={114} width={90} height={14} rx={3} {...machined} />\n        {[43, 117].flatMap(x => [47, 121].map(y => <circle key={`${x}-${y}`} cx={x} cy={y} r={2.5} {...cast} />))}\n        <rect x={49} y={35} width={62} height={100} rx={7} {...shell} />\n        <rect x={55} y={92} width={50} height={33} rx={3} {...cast} />\n        {[100, 107, 114].map(y => <path key={y} d={`M 63 ${y} H 97`} stroke={palette.metal} strokeWidth={1.2} />)}\n        <circle cx={80} cy={69} r={22} {...cast} />\n        <circle cx={80} cy={69} r={17} {...machined} />\n      </g>\n      {showScale && <g transform=\"translate(80 69)\" fill=\"none\" stroke={palette.grid} strokeWidth={0.6}>\n        <circle r={48} strokeDasharray=\"1 3\" />\n        {[-180, -90, 0, 90].map(degrees => <path key={degrees} d=\"M 0 -45 V -50\" transform={`rotate(${degrees})`} />)}\n      </g>}\n      <g transform=\"translate(80 69)\">\n        <g data-horn transform={`rotate(${readout})`}>\n          {arms.map(degrees => <g key={degrees} data-horn-arm transform={`rotate(${degrees})`}>\n            <path d=\"M -6 0 L -4 -35 Q 0 -42 4 -35 L 6 0 Z\" {...machined} />\n            {[16, 24, 32].map(y => <circle key={y} cy={-y} r={1.6} fill={palette.dark} />)}\n          </g>)}\n          <circle r={8} {...shell} />\n          <circle r={3.5} {...cast} />\n          <path d=\"M -2 0 h 4 M 0 -2 v 4\" stroke={palette.metal} strokeWidth={0.7} />\n          <path d=\"M -2 -8 L 0 -11 L 2 -8\" fill=\"none\" stroke={palette.accent} strokeWidth={1} />\n        </g>\n      </g>\n      </Frame>\n      <text x={80} y={149} textAnchor=\"middle\" fontFamily=\"ui-monospace, monospace\" fontSize={5} fill={palette.foreground}>{readout}°</text>\n      {label && <text x={90} y={177} textAnchor=\"middle\" fontFamily=\"ui-monospace, monospace\" fontSize={4.5} fill={palette.foreground}>{label}</text>}\n    </svg>\n  )\n}\n\n/**\n * Where the shaft is aiming at `clock`. `step` deliberately returns a square\n * wave: the slew rate in {@link ServoMotorProps.speed}'s loop is what turns it\n * into the travel between positions, the way a real servo gets there.\n */\nexport function servoGoal(behavior: ServoBehavior, clock: number) {\n  if (behavior === \"static\") return 0\n  const t = Number.isFinite(clock) ? ((clock % 1) + 1) % 1 : 0\n  switch (behavior) {\n    case \"step\":\n      return STEPS[Math.min(STEPS.length - 1, Math.floor(t * STEPS.length))]\n    case \"hunt\":\n      return Math.sin(t * Math.PI * 12) * 1.6 + Math.sin(t * Math.PI * 22) * 0.9\n    default:\n      return Math.sin(t * Math.PI * 2) * 90\n  }\n}\n\nexport { ServoMotor }\n",
      "type": "registry:ui",
      "target": "@ui/servo-motor.tsx"
    }
  ],
  "categories": [
    "robotics",
    "actuators"
  ],
  "type": "registry:ui"
}