For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Casing droid

An armoured conical casing unit: hemisphere skirt, caged neck, rotating dome, an eyestalk that swings and elevates, and a swappable manipulator.

Open in workbench
CASING / 22
view
variant
skirt
dome
collar
lamps
tool
emitter
rows
3
studs
4
rings
3
stalk
1.00×
drive

Bring the pointer over it and the eyestalk comes round to you.

Theming

Set a role and the same CSS goes in your own app — every robot under it follows.

Install

bunx --bun shadcn@latest add https://robocn.dev/r/casing-droid.json

Notes

  • Front elevation is the drawing it always had. The casing is a cone of revolution with a dome on top, and the manipulator and emitter reach forward out of the shoulders rather than sideways across the picture.
  • Heading is cyclic: 360 renders identically to 0 rather than sticking at a limit.
  • An original armoured-casing archetype; the emitter is a ringed rod with no projectile effect.

Usage

import { CasingDroid } from "@/components/ui/casing-droid"

<CasingDroid domeAngle={40} eyeElevation={-12} manipulator="clamp" emitter lamps />

Props

PropTypeDefaultDescription
view"plan" | "front" | "profile" | "iso""front"Where the camera stands. One droid, four projections: straight down, straight on, side elevation, or three-quarter from above.
skirt"banded" | "smooth" | "ribbed""banded"Lower casing treatment; the flare geometry is shared.
hemisphereRowsnumber3Rows of skirt hemispheres, clamped to 2–4.
hemisphereColumnsnumber4Hemispheres across each row, clamped to 3–6; the studs shrink to fit.
dome"round" | "flat" | "faceted""round"Upper sensor-shell silhouette, drawn into the same envelope.
collar"slats" | "mesh" | "plain""slats"Mid-section treatment between the skirt and the neck.
neckRingsnumber3Rings in the neck cage, clamped to 2–5; the cage spans the same gap either way.
stalkLengthnumber1Eyestalk reach as a multiple of standard, clamped to 0.5–1.8.
domeAnglenumber0Dome heading in degrees, wrapped to −180..180; swings and foreshortens the eyestalk.
eyeElevationnumber0Eyestalk pitch in degrees, clamped to −28..28.
manipulator"none" | "suction" | "clamp" | "probe""suction"Tool on the left appendage.
emitter"none" | "rod" | "dish" | "array""array"Hardware on the right appendage: bare rod, dish, or the ringed array.
lamps"none" | "pair" | "quad""pair"Dome lamp arrangement.
behavior"patrol" | "survey" | "idle" | "static""patrol"Patrol holds a bearing, swings to the next and holds again; survey sweeps the room; idle is the drift of a machine that is still switched on.
speednumber0.18Sweeps per second.
animatebooleantrueOff parks the machine at phase and stops rendering. A reduced-motion preference does the same.
pausedbooleanfalseFreeze where it stands.
phasenumber0Seconds of offset, so a row of machines breaks step.
interactivebooleantrueThe eyestalk follows the pointer — which is, after all, the entire point of this machine.
variant"solid" | "outline" | "blueprint" | "wire""solid"How the machine is painted. Geometry never changes between variants.
size"xs" | "sm" | "md" | "lg" | "xl" | number"md"Rendered width in pixels, or a step on the scale.
showGroundbooleantrueDraw the contact line or shadow beneath the droid.
signal"idle" | "ready" | "warning"—Status-lamp state using neutral, accent, or shell colour; each component documents its visual default in the demo.
labelstring—Optional technical caption under the drawing.
colorstringvar(--robot-shell)Body panels — the colour the machine reads as.
accentstringvar(--robot-accent)Status colour: tip light, live tool, readouts.
metalstringvar(--robot-metal)Bare machined parts: collars, bolts, tool bodies.
darkstringvar(--robot-dark)Cast joints, base, shadow side.
palettePartial<RobotPalette>—Override any subset of roles at once, including glow and grid.

Source

src/components/ui/casing-droid.tsx
"use client"

import * as React from "react"

import { usePointerTarget } from "@/hooks/use-pointer-target"
import { useRobotClock } from "@/hooks/use-robot-motion"

import { clamp, toRadians } from "@/lib/robocn/kinematics"
import {
  aboutPoint,
  capsulePath,
  circleFootprint,
  extrudedPath,
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotCamera,
  robotSurface,
  type RobotPaletteProps,
  type RobotSize,
  type RobotVariant,
  type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

export type CasingDroidSkirt = "banded" | "smooth" | "ribbed"
export type CasingDroidManipulator = "none" | "suction" | "clamp" | "probe"
export type CasingDroidDome = "round" | "flat" | "faceted"
export type CasingDroidCollar = "slats" | "mesh" | "plain"
export type CasingDroidEmitter = "none" | "rod" | "dish" | "array"
export type CasingDroidLamps = "none" | "pair" | "quad"
export type CasingDroidSignal = "idle" | "ready" | "warning"
export type CasingDroidBehavior = "patrol" | "survey" | "idle" | "static"

/** The droid is drawn standing straight on; that is the camera it defaults to. */
const NATIVE_VIEW: RobotView = "front"
/** Where it stands in the frame. */
const CENTRE = 85
const GROUND = 202

const viewNames: Record<RobotView, string> = {
  plan: "plan view",
  front: "front elevation",
  profile: "side elevation",
  iso: "isometric view",
}

export interface CasingDroidProps
  extends Omit<React.ComponentProps<"svg">, "color">,
    RobotPaletteProps {
  size?: RobotSize | number
  variant?: RobotVariant
  /** Lower casing treatment. Geometry of the flare is shared; the surface is not. */
  /** Where the camera stands. One droid, four projections. */
  view?: RobotView
  skirt?: CasingDroidSkirt
  /** Rows of hemispheres on the skirt, clamped to 2–4. */
  hemisphereRows?: number
  /** Hemispheres across each row, clamped to 3–6. */
  hemisphereColumns?: number
  /** Upper sensor-shell silhouette. */
  dome?: CasingDroidDome
  /** Mid-section treatment between the skirt and the neck. */
  collar?: CasingDroidCollar
  /** Rings in the neck cage, clamped to 2–5. */
  neckRings?: number
  /** Eyestalk length as a multiple of the standard reach, clamped to 0.5–1.8. */
  stalkLength?: number
  /** Dome heading in degrees, wrapped to −180..180. Swings the eyestalk around the casing. Omit and `behavior` turns it. */
  domeAngle?: number
  /** Eyestalk pitch in degrees, clamped to −28..28. Omit and `behavior` works it. */
  eyeElevation?: number
  /** What the droid does when the dome is not being driven. */
  behavior?: CasingDroidBehavior
  /** Sweeps per second. */
  speed?: number
  animate?: boolean
  paused?: boolean
  phase?: number
  /** The eyestalk follows the pointer, because of course it does. */
  interactive?: boolean
  /** Tool on the left appendage. */
  manipulator?: CasingDroidManipulator
  /** Hardware on the right appendage. */
  emitter?: CasingDroidEmitter
  /** Dome lamp arrangement. */
  lamps?: CasingDroidLamps
  signal?: CasingDroidSignal
  showGround?: boolean
  label?: string
}

function CasingDroid({
  size = "md",
  variant = "solid",
  view = NATIVE_VIEW,
  skirt = "banded",
  hemisphereRows = 3,
  hemisphereColumns = 4,
  dome = "round",
  collar = "slats",
  neckRings = 3,
  stalkLength = 1,
  domeAngle,
  eyeElevation,
  behavior = "patrol",
  speed = 0.18,
  animate = true,
  paused = false,
  phase = 0,
  interactive = true,
  manipulator = "suction",
  emitter = "array",
  lamps = "pair",
  signal = "ready",
  showGround = true,
  label,
  color,
  accent,
  metal,
  dark,
  glow,
  grid,
  palette: paletteOverride,
  className,
  style,
  ...props
}: CasingDroidProps) {
  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
  const width = resolveRobotSize(size)
  const svgRef = React.useRef<SVGSVGElement>(null)
  const clock = useRobotClock({ speed, animate: animate && behavior !== "static", paused, phase })
  // An eyestalk that follows you is the entire point of this machine.
  const pointer = usePointerTarget(svgRef, {
    enabled: interactive && domeAngle === undefined && !paused,
    toWorld: React.useCallback((unit: { x: number; y: number }) => ({
      x: (unit.x - 0.5) * 2,
      y: (unit.y - 0.5) * 2,
    }), []),
  })
  const scripted = casingDroidPose(behavior, clock)
  const watched = pointer.target
  const heading = wrap180(domeAngle ?? (watched ? clamp(watched.x, -1, 1) * 115 : scripted.dome))
  const pitch = finiteClamp(
    eyeElevation ?? (watched ? clamp(-watched.y, -1, 1) * 22 : scripted.eye),
    -28,
    28,
  )
  const rows = Math.round(finiteClamp(hemisphereRows, 2, 4))
  const columns = Math.round(finiteClamp(hemisphereColumns, 3, 6))
  const rings = Math.round(finiteClamp(neckRings, 2, 5))
  const reach = finiteClamp(stalkLength, 0.5, 1.8) || 1
  const shell = robotSurface("shell", variant, palette)
  const machined = robotSurface("metal", variant, palette)
  const cast = robotSurface("dark", variant, palette)
  const signalColor = signal === "warning" ? palette.shell : signal === "ready" ? palette.accent : palette.metal

  // The dome turns about a vertical axis, so the eyestalk swings sideways and
  // foreshortens instead of orbiting the drawing plane.
  const swing = Math.sin(toRadians(heading))
  const depth = Math.cos(toRadians(heading))
  const stalk = px((17 + 11 * depth) * reach)
  // Studs are laid into the cone rather than onto a fixed grid, so a taller or
  // busier skirt still keeps every hemisphere inside the silhouette.
  const rowTop = -64
  const rowBottom = -12
  const rowGap = rows > 1 ? (rowBottom - rowTop) / (rows - 1) : 0
  // The drawing is a front elevation, so it goes through `wall` where the
  // droid stands and comes out untouched straight on. The casing is a cone of
  // revolution and the dome a hemisphere on top of it; the manipulator and the
  // emitter reach forward out of the shoulders rather than sideways across it.
  const camera = robotCamera(view)
  const offAxis = view !== NATIVE_VIEW
  const face = aboutPoint(camera.wall(), CENTRE, GROUND)
  const Frame = (face ? "g" : React.Fragment) as React.FC<{
    transform?: string
    children?: React.ReactNode
  }>
  const frame = face ? { transform: face } : {}
  /** A point in the frame, `deep` units toward the reader. */
  const at = (x: number, y: number, deep = 0) => camera.project(-x, -y, -deep)
  /** A drum: round in plan, standing between two heights above the ground. */
  const drum = (radius: number, top: number, bottom: number, x = 0) =>
    extrudedPath(circleFootprint(-x, 0, radius, 14), camera, top, bottom)


  return (
    <svg
      ref={svgRef}
      role="img"
      aria-label={`Casing droid, ${skirt} skirt, ${dome} dome, ${manipulator} manipulator, ${viewNames[view] ?? viewNames.front}`}
      viewBox="0 0 170 230"
      width={width}
      height={px(width * 1.35)}
      className={cn("max-w-full select-none", className)}
      style={{ color: palette.foreground, ...style }}
      {...props}
    >
      {variant === "blueprint" && (
        <g fill="none" stroke={palette.grid} strokeWidth={0.5} opacity={0.45}>
          <path d="M 85 8 V 214 M 14 206 H 156" strokeDasharray="2 3" />
          <path d="M 30 26 H 140 V 204 H 30 Z" strokeDasharray="2 3" />
        </g>
      )}
      {showGround && <ellipse cx={85} cy={205} rx={48} ry={6} fill={palette.dark} opacity={0.14} />}

      {offAxis && <g data-solids transform={`translate(${CENTRE} ${GROUND})`}>
        {/* The skirt is a cone, so it is stacked bands rather than one drum. */}
        {[0, 1, 2, 3].map(band => (
          <path
            key={band}
            d={extrudedPath(
              circleFootprint(0, 0, 42 - band * 3.8, 16),
              camera,
              (band + 1) * 19,
              band * 19,
            )}
            {...shell}
          />
        ))}
        <path d={drum(27, 104, 76)} {...machined} />
        <path d={drum(21, 138, 104)} {...cast} />
        <path d={drum(24, 160, 138, swing * 3)} {...shell} />
        {([[-26, "manipulator"], [26, "emitter"]] as const).map(([x, name]) => (
          <path key={name} d={capsulePath(at(x, -104, 6), at(x * 1.1, -104, 34), 4)} {...machined} />
        ))}
      </g>}
      <Frame {...frame}>
      <g data-view={view} transform="translate(85 202)">
        <g data-skirt={skirt}>
          <path d="M -42 0 L -27 -76 H 27 L 42 0 Z" {...shell} />
          {skirt === "ribbed" && (
            <g stroke={palette.dark} strokeWidth={1} opacity={0.55}>
              <path d="M -21 -4 L -14 -74 M 0 -4 V -74 M 21 -4 L 14 -74" />
            </g>
          )}
          {skirt !== "smooth" &&
            Array.from({ length: rows }, (_, row) => {
              const y = rowTop + row * rowGap
              // The casing tapers, so every row measures the cone at its own height.
              const half = skirtHalfWidth(y)
              const radius = Math.min(6.4, (half * 1.5) / columns)
              const span = Math.max(0, half - radius - 3)
              return (
                <g key={row}>
                  {Array.from({ length: columns }, (_, column) => {
                    const slot = columns === 1 ? 0 : (column / (columns - 1)) * 2 - 1
                    return <circle key={column} cx={px(slot * span)} cy={px(y)} r={px(radius)} {...machined} />
                  })}
                </g>
              )
            })}
          <path d="M -42 -2 H 42" stroke={palette.dark} strokeWidth={1.2} fill="none" />
        </g>

        <g data-belt data-collar={collar}>
          <path d="M -28 -76 H 28 L 25 -98 H -25 Z" {...cast} />
          {collar === "slats" && (
            <g stroke={palette.metal} strokeWidth={1.2} opacity={0.7}>
              <path d="M -18 -78 V -96 M -6 -78 V -96 M 6 -78 V -96 M 18 -78 V -96" />
            </g>
          )}
          {collar === "mesh" && (
            <g stroke={palette.metal} strokeWidth={0.9} opacity={0.6}>
              <path d="M -24 -78 L -10 -96 M -12 -78 L 2 -96 M 0 -78 L 14 -96 M 12 -78 L 24 -96" />
              <path d="M -24 -96 L -10 -78 M -12 -96 L 2 -78 M 0 -96 L 14 -78 M 12 -96 L 24 -78" />
            </g>
          )}
          <path d="M -27 -98 H 27 L 24 -118 H -24 Z" {...machined} />
          <rect data-lamp-bar x={-16} y={-116} width={32} height={7} rx={3} {...cast} />
        </g>

        <g data-neck>
          {Array.from({ length: rings }, (_, ring) => (
            // The cage always spans the same gap, so more rings means a finer stack.
            <rect
              data-neck-ring
              key={ring}
              x={-16}
              y={px(-120 - (ring * 16) / (rings - 1 || 1))}
              width={32}
              height={5}
              rx={2.5}
              {...cast}
            />
          ))}
          <path d="M -12 -120 V -138 M 0 -120 V -138 M 12 -120 V -138" stroke={palette.metal} strokeWidth={1.4} fill="none" />
        </g>

        <g data-dome data-dome-shape={dome} transform={`translate(${px(swing * 3)} -138)`}>
          <path d={domeOutline(dome)} {...shell} />
          <path d="M -25 0 H 25" stroke={palette.dark} strokeWidth={1.2} fill="none" />
          {lampPositions(lamps).map(([x, y], index) => (
            <circle
              data-lamp={index}
              key={`${x}-${y}`}
              cx={px(x + swing * 4)}
              cy={y}
              r={4.2}
              fill={signalColor}
              stroke={palette.dark}
              strokeWidth={0.6}
            />
          ))}
          <g data-eyestalk transform={`translate(${px(swing * 16)} -6) rotate(${px(-pitch)})`}>
            <rect x={0} y={-2} width={stalk} height={4} rx={2} {...machined} />
            <circle cx={stalk} r={5.5} {...cast} />
            <circle cx={stalk} r={2.6} fill={signalColor} />
          </g>
        </g>

        <g data-manipulator={manipulator} transform="translate(-26 -104)">
          {manipulator !== "none" && (
            <>
              <circle r={5} {...cast} />
              <rect x={-30} y={-2.5} width={30} height={5} rx={2.5} {...machined} />
              {manipulator === "suction" && <path d="M -30 -9 L -42 -13 V 13 L -30 9 Z" {...cast} />}
              {manipulator === "clamp" && (
                <g {...machined}>
                  <path d="M -30 -3 L -43 -11 L -40 -4 L -30 -1 Z" />
                  <path d="M -30 3 L -43 11 L -40 4 L -30 1 Z" />
                </g>
              )}
              {manipulator === "probe" && (
                <>
                  <rect x={-41} y={-3.5} width={11} height={7} rx={2} {...cast} />
                  <circle cx={-44} r={3} fill={signalColor} />
                </>
              )}
            </>
          )}
        </g>

        {emitter !== "none" && (
          <g data-emitter={emitter} transform="translate(26 -104)">
            <circle r={5} {...cast} />
            <rect x={0} y={-2} width={26} height={4} rx={2} {...machined} />
            {emitter === "array" &&
              [8, 15, 22].map((x) => (
                <circle key={x} cx={x} r={4.5} fill="none" stroke={palette.metal} strokeWidth={1.4} />
              ))}
            {emitter === "dish" && <path d="M 24 -11 L 34 -14 V 14 L 24 11 Z" {...cast} />}
            <circle cx={28} r={2.8} fill={signalColor} />
          </g>
        )}
      </g>
      </Frame>
      {label && (
        <text x={85} y={224} textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize={6} fill={palette.foreground}>
          {label}
        </text>
      )}
    </svg>
  )
}

/** Half-width of the skirt at a given height: 42 at the base, 27 at the top. */
const skirtHalfWidth = (y: number) => 42 - (Math.min(0, Math.max(-76, y)) / -76) * 15

/** The three dome silhouettes, all filling the same 50×22 envelope. */
const domeOutline = (dome: CasingDroidDome) => {
  switch (dome) {
    case "flat":
      return "M -25 0 V -12 Q -25 -17 -18 -17 H 18 Q 25 -17 25 -12 V 0 Z"
    case "faceted":
      return "M -25 0 L -20 -14 L -9 -22 H 9 L 20 -14 L 25 0 Z"
    default:
      return "M -25 0 A 25 22 0 0 1 25 0 Z"
  }
}

/** Where the dome lamps sit, in dome-local coordinates. */
const lampPositions = (lamps: CasingDroidLamps): [number, number][] => {
  switch (lamps) {
    case "none":
      return []
    case "quad":
      return [
        [-15, -9],
        [-6, -16],
        [6, -16],
        [15, -9],
      ]
    default:
      return [
        [-13, -13],
        [13, -13],
      ]
  }
}

const finiteClamp = (value: number, min: number, max: number) =>
  Number.isFinite(value) ? clamp(value, min, max) : 0

/** Heading is cyclic, so out-of-range input wraps instead of sticking at a limit. */
const wrap180 = (value: number) => {
  if (!Number.isFinite(value)) return 0
  return px(((((value + 180) % 360) + 360) % 360) - 180)
}

/** The sweep each behavior traces, sampled at `clock`. Pure, so tests can read it. */
export function casingDroidPose(behavior: CasingDroidBehavior, clock: number) {
  const t = Number.isFinite(clock) ? clock : 0
  switch (behavior) {
    case "survey":
      return { dome: Math.sin(t * Math.PI * 2) * 165, eye: Math.sin(t * Math.PI * 4) * 16 }
    case "idle":
      return { dome: Math.sin(t * Math.PI * 0.6) * 14, eye: Math.sin(t * Math.PI * 0.9) * 5 }
    case "static":
      return { dome: 0, eye: 0 }
    default: {
      // Patrol holds a bearing, swings to the next one, and holds again.
      const cycle = ((t % 1) + 1) % 1
      const dome = cycle < 0.4 ? -70 : cycle < 0.5 ? -70 + (cycle - 0.4) * 1400 : cycle < 0.9 ? 70 : 70 - (cycle - 0.9) * 1400
      return { dome, eye: Math.sin(t * Math.PI * 1.4) * 7 }
    }
  }
}

export { CasingDroid }