{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "celestial-asteroid",
  "title": "Asteroid",
  "description": "An irregular body whose radius is a deterministic sum of cosine lobes, so its silhouette genuinely changes as it turns — and it tumbles about an axis that is itself going round.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/celestial-geometry.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/celestial-asteroid.tsx",
      "content": "\"use client\"\n\n/**\n * celestial-asteroid — the one body here whose outline changes as it turns.\n *\n * Every other body in this family is a sphere, so its rotation is invisible\n * except for what is painted on it. This one has a real shape: `lumpyRadius`\n * sums deterministic cosine lobes over direction, and the surface is that\n * radius field applied to the unit sphere. Turn it and the *silhouette*\n * changes, because there is a different amount of rock in the way.\n *\n * So the outline cannot be a hull. A hull would smooth out every hollow the\n * lobes make, which is exactly the information this component exists to show.\n * Instead the surface is sampled densely, projected, and the farthest sample in\n * each angular bin about the centre is kept — the true silhouette of a body\n * that is star-shaped about its own centre, concavities included.\n *\n * And it tumbles rather than spins: the body turns about its pole while the\n * pole itself goes round, at a rate that is not a whole multiple of the first,\n * so no two frames of the cycle repeat.\n *\n * Design note: docs/celestial-bodies.md.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp, type Vec2, type Vec3 } from \"@/lib/robocn/kinematics\"\nimport {\n  bodyFrame,\n  illumination,\n  lumpyRadius,\n  orbitalState,\n  sphereLattice,\n  surfacePoint,\n} from \"@/lib/robocn/celestial\"\nimport {\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\nconst VIEW_SIZE = 200\nconst ORIGIN = { x: 100, y: 100 }\nconst NATIVE_VIEW: RobotView = \"front\"\n\nconst RADIUS = 56\n/** Directions the outline is built at, and depth samples along each one. */\nconst OUTLINE_BINS = 64\nconst OUTLINE_DEPTH = 11\n/** Degrees of tumble per second while it returns to its behaviour. */\nconst TUMBLE_RATE = 60\n/**\n * The pole goes round at this fraction of the body's own turn. Deliberately\n * not a whole number: that is what makes a tumble rather than a spin.\n */\nconst PRECESSION_RATIO = 0.382\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\nexport type AsteroidBehavior = \"tumble\" | \"spin\" | \"drift\" | \"static\"\nexport type AsteroidBody = \"rubble\" | \"monolith\" | \"contact\"\n\n/** Lobe count and depth: how broken up the rock is. */\nconst bodies: Record<AsteroidBody, { lobes: number; depth: number }> = {\n  rubble: { lobes: 9, depth: 0.5 },\n  monolith: { lobes: 5, depth: 0.34 },\n  // Two deep lobes: a body that came together out of two, and looks it.\n  contact: { lobes: 2, depth: 0.6 },\n}\n\nexport interface CelestialAsteroidProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  size?: RobotSize | number\n  variant?: RobotVariant\n  /** Where the camera stands. This body has a shape, so every angle differs. */\n  view?: RobotView\n  /** Controlled rotation, in degrees. The pole follows it. Stops the loop. */\n  tumble?: number\n  /** What the rock does when `tumble` is not supplied. */\n  behavior?: AsteroidBehavior\n  /** Cycles per second: one revolution. */\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  phase?: number\n  /** Drag across it to turn it, or arrow-key it. */\n  interactive?: boolean\n  onTumbleChange?: (tumble: number) => void\n  /** Which kind of body: it sets the lobe count and how deep they cut. */\n  body?: AsteroidBody\n  /** Any integer. The same seed is the same rock, every render. */\n  seed?: number\n  /** Craters on the surface. Clamped 0–80. */\n  craters?: number\n  /** Where the light is, in degrees round the body. 0 is behind the viewer. */\n  sun?: number\n  /** A companion on its own orbit. */\n  moonlet?: boolean\n  signal?: \"idle\" | \"ready\" | \"warning\"\n  label?: string\n}\n\nfunction CelestialAsteroid({\n  size = \"md\",\n  variant = \"solid\",\n  view = NATIVE_VIEW,\n  tumble,\n  behavior = \"tumble\",\n  speed = 0.08,\n  animate = true,\n  paused = false,\n  phase = 0,\n  interactive = false,\n  onTumbleChange,\n  body = \"rubble\",\n  seed = 9,\n  craters = 18,\n  sun = 34,\n  moonlet = false,\n  signal = \"ready\",\n  label,\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  role,\n  tabIndex,\n  onKeyDown,\n  onBlur,\n  ...props\n}: CelestialAsteroidProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const controlled = tumble !== undefined\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n\n  const hold = controlled ? (Number.isFinite(tumble) ? tumble : 0) : held\n  const goal = React.useCallback((clock: number) => asteroidGoal(behavior, clock), [behavior])\n  const motion = useRobotScalar(goal, {\n    rate: TUMBLE_RATE,\n    hold,\n    speed,\n    animate: animate && !controlled && behavior !== \"static\",\n    paused,\n    phase,\n  })\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Number.isFinite(next) ? next : 0\n      setHeld(bounded)\n      onTumbleChange?.(bounded)\n    },\n    [onTumbleChange],\n  )\n\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit: Vec2) => apply((unit.x - 0.5) * 720), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const turn = Number.isFinite(motion.value) ? motion.value : 0\n  const shape = bodies[body] ?? bodies.rubble\n  const grain = Math.round(Number.isFinite(seed) ? seed : 9)\n  const pits = Math.round(clamp(Number.isFinite(craters) ? craters : 18, 0, 80))\n  const bearing = Number.isFinite(sun) ? sun : 34\n  const field = { lobes: shape.lobes, depth: shape.depth, seed: grain }\n  // One number is the whole mechanism: the body's turn also carries its pole\n  // round, at a rate that shares no whole factor with it.\n  const frame = bodyFrame({\n    tilt: 34 + 26 * Math.sin((turn * PRECESSION_RATIO * Math.PI) / 180),\n    precession: turn * PRECESSION_RATIO,\n    spin: behavior === \"drift\" && !controlled ? turn * 0.35 : turn,\n  })\n\n  const camera = robotCamera(view)\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n  const signalColor =\n    signal === \"warning\" ? palette.shell : signal === \"ready\" ? palette.accent : palette.metal\n\n  const at = (point: Vec3): Vec2 => {\n    const screen = camera.project(point.x, point.y, point.z)\n    return { x: ORIGIN.x + screen.x, y: ORIGIN.y + screen.y }\n  }\n  const eye = unit({\n    x: camera.depth(1, 0, 0),\n    y: camera.depth(0, 1, 0),\n    z: camera.depth(0, 0, 1),\n  })\n  const front = (p: Vec3) => p.x * eye.x + p.y * eye.y + p.z * eye.z\n  const light = bearingDirection(bearing)\n\n  /* ---- the rock -------------------------------------------------------- */\n\n  // The screen plane, in world directions.\n  const reference: Vec3 = Math.abs(eye.y) > 0.99 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 }\n  const across = unit(crossOf(reference, eye))\n  const upward = crossOf(eye, across)\n  const bodyLocal = (d: Vec3): Vec3 => ({\n    x: dot3(d, frame.right),\n    y: dot3(d, frame.up),\n    z: dot3(d, frame.forward),\n  })\n  const surfaceAt = (d: Vec3) => scale3(d, RADIUS * lumpyRadius(bodyLocal(d), field))\n\n  /**\n   * The outline, exactly rather than by binning a point cloud. Every direction\n   * that projects onto one screen bearing lies on a single great circle\n   * through the line of sight — so sweeping that circle and keeping the point\n   * that projects farthest out gives the true silhouette of a body that is\n   * star-shaped about its own centre, concavities and all, and smoothly.\n   */\n  const rim = Array.from({ length: OUTLINE_BINS }, (_, index) => {\n    const bearing = (index / OUTLINE_BINS) * Math.PI * 2\n    const plane: Vec3 = {\n      x: across.x * Math.cos(bearing) + upward.x * Math.sin(bearing),\n      y: across.y * Math.cos(bearing) + upward.y * Math.sin(bearing),\n      z: across.z * Math.cos(bearing) + upward.z * Math.sin(bearing),\n    }\n    let best: { point: Vec3; screen: Vec2; reach: number; lit: boolean } | null = null\n    for (let step = 0; step <= OUTLINE_DEPTH; step++) {\n      // Toward the camera only: the far half of the body is behind this one.\n      const lean = (-Math.PI / 2 + (Math.PI * step) / OUTLINE_DEPTH) * 0.94\n      const direction: Vec3 = {\n        x: plane.x * Math.cos(lean) + eye.x * Math.sin(lean),\n        y: plane.y * Math.cos(lean) + eye.y * Math.sin(lean),\n        z: plane.z * Math.cos(lean) + eye.z * Math.sin(lean),\n      }\n      const point = surfaceAt(direction)\n      const screen = at(point)\n      const reach = Math.hypot(screen.x - ORIGIN.x, screen.y - ORIGIN.y)\n      if (!best || reach > best.reach) {\n        best = { point, screen, reach, lit: illumination(direction, light) > 0 }\n      }\n    }\n    return best!\n  })\n  const outline = polygonPath(rim.map((edge) => edge.screen))\n\n  // The day-night line: the great circle of directions square to the light,\n  // with the body's own radius along each one. Analytic, so it is smooth.\n  const edge = (() => {\n    const pole: Vec3 = Math.abs(light.y) > 0.9 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 }\n    const a = unit(crossOf(pole, light))\n    const c = crossOf(light, a)\n    return Array.from({ length: 96 }, (_, index) => {\n      const angle = (index / 96) * Math.PI * 2\n      const direction = {\n        x: a.x * Math.cos(angle) + c.x * Math.sin(angle),\n        y: a.y * Math.cos(angle) + c.y * Math.sin(angle),\n        z: a.z * Math.cos(angle) + c.z * Math.sin(angle),\n      }\n      return { point: surfaceAt(direction), facing: front(direction) > 0 }\n    })\n      .filter((sample) => sample.facing)\n      .map((sample) => at(sample.point))\n  })()\n\n  // The night side: the body's own outline outside, the terminator inside.\n  const shadow = nightPath(\n    rim.map((point) => point.screen),\n    rim.map((point) => !point.lit),\n    edge,\n  )\n\n  const pitList = sphereLattice(pits).map((site, index) => {\n    const local = surfacePoint(frame, 1, latitudeOf(site), longitudeOf(site))\n    const point = surfaceAt(local)\n    const facing = front(local)\n    const screen = at(point)\n    const scale = 2 + ((((index * 41 + grain * 17) % 13) + 13) % 13) / 13 * 4\n    const outward = Math.hypot(screen.x - ORIGIN.x, screen.y - ORIGIN.y) || 1\n    return {\n      index,\n      shown: facing > 0.18,\n      screen,\n      major: scale,\n      minor: scale * Math.max(0.12, facing),\n      angle:\n        (Math.atan2((screen.y - ORIGIN.y) / outward, (screen.x - ORIGIN.x) / outward) * 180) /\n          Math.PI +\n        90,\n      lit: illumination(local, light) > 0,\n    }\n  })\n\n  const companion = moonlet\n    ? (() => {\n        const state = orbitalState(\n          {\n            semiMajor: RADIUS * 1.9,\n            eccentricity: 0.3,\n            inclination: 28,\n            node: 40,\n            period: 5,\n          },\n          motion.clock,\n        )\n        return { point: at(state.position), behind: front(state.position) < 0 }\n      })()\n    : null\n\n  const readout = ((Math.round(turn) % 360) + 360) % 360\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={`Celestial asteroid, ${body} body at ${readout} degrees, ${viewNames[view] ?? viewNames.front}`}\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 360 : 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 ? 30 : 10, 90)\n        if (delta !== 0) apply(turn + 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 ${VIEW_SIZE} ${VIEW_SIZE}`}\n      width={width}\n      height={width}\n      className={cn(\n        \"max-w-full select-none\",\n        interactive &&\n          \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        dragging && \"cursor-grabbing\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      {variant === \"blueprint\" && (\n        <g fill=\"none\" stroke={palette.grid} strokeWidth={0.5} opacity={0.45}>\n          <path d=\"M 8 100 H 192 M 100 8 V 192\" strokeDasharray=\"2 3\" />\n          {/* The mean radius the lobes depart from. */}\n          <circle cx={100} cy={100} r={px(RADIUS)} strokeDasharray=\"3 3\" />\n          <path\n            data-axis\n            d={linePath([\n              at(scale3(surfacePoint(frame, 1, 90, 0), RADIUS * 1.35)),\n              at(scale3(surfacePoint(frame, 1, -90, 0), RADIUS * 1.35)),\n            ])}\n            strokeDasharray=\"3 2\"\n          />\n        </g>\n      )}\n\n      <g data-frame data-view={view}>\n        {companion?.behind && (\n          <circle data-moonlet cx={px(companion.point.x)} cy={px(companion.point.y)} r={5} {...cast} />\n        )}\n\n        <path data-body d={outline} {...shell} />\n        {shadow && <path data-shadow d={shadow} {...cast} opacity={0.5} />}\n\n        <g data-craters>\n          {pitList\n            .filter((pit) => pit.shown)\n            .map((pit) => (\n              <g\n                key={pit.index}\n                data-crater={pit.index}\n                transform={`translate(${px(pit.screen.x)} ${px(pit.screen.y)}) rotate(${px(pit.angle)})`}\n              >\n                <ellipse\n                  rx={px(pit.major)}\n                  ry={px(pit.minor)}\n                  {...machined}\n                  opacity={pit.lit ? 0.5 : 0.24}\n                />\n              </g>\n            ))}\n        </g>\n\n        {companion && !companion.behind && (\n          <circle\n            data-moonlet\n            cx={px(companion.point.x)}\n            cy={px(companion.point.y)}\n            r={5}\n            {...machined}\n          />\n        )}\n\n        <circle\n          data-lamp\n          cx={ORIGIN.x}\n          cy={px(ORIGIN.y + RADIUS + 24)}\n          r={2.2}\n          fill={signalColor}\n          className={signal === \"ready\" ? \"robocn-pulse\" : undefined}\n        />\n      </g>\n\n      {label && (\n        <text\n          x={100}\n          y={194}\n          textAnchor=\"middle\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={6}\n          fill={palette.foreground}\n        >\n          {label}\n        </text>\n      )}\n    </svg>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* behaviour                                                                   */\n/* -------------------------------------------------------------------------- */\n\n/** How far the rock has turned at `clock`, in degrees. */\nexport function asteroidGoal(behavior: AsteroidBehavior, clock: number): number {\n  if (behavior === \"static\" || !Number.isFinite(clock)) return 0\n  switch (behavior) {\n    case \"tumble\":\n      return clock * 360\n    case \"spin\":\n      return clock * 540\n    // Barely turning, the way a body nobody has hit in a long time turns.\n    case \"drift\":\n      return clock * 90\n    default:\n      return 0\n  }\n}\n\n/* -------------------------------------------------------------------------- */\n/* geometry helpers                                                            */\n/* -------------------------------------------------------------------------- */\n\nconst scale3 = (v: Vec3, s: number): Vec3 => ({ x: v.x * s, y: v.y * s, z: v.z * s })\n\nconst dot3 = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z\n\nconst crossOf = (a: Vec3, b: Vec3): Vec3 => ({\n  x: a.y * b.z - a.z * b.y,\n  y: a.z * b.x - a.x * b.z,\n  z: a.x * b.y - a.y * b.x,\n})\n\nfunction unit(v: Vec3): Vec3 {\n  const length = Math.hypot(v.x, v.y, v.z)\n  return length > 1e-9 ? { x: v.x / length, y: v.y / length, z: v.z / length } : { x: 0, y: 0, z: 1 }\n}\n\nconst latitudeOf = (site: Vec3) => (Math.asin(clamp(site.y, -1, 1)) * 180) / Math.PI\nconst longitudeOf = (site: Vec3) => (Math.atan2(site.z, site.x) * 180) / Math.PI\n\n/** A horizontal direction from a bearing: 0 is behind the viewer. */\nfunction bearingDirection(bearing: number): Vec3 {\n  const a = ((Number.isFinite(bearing) ? bearing : 0) * Math.PI) / 180\n  return { x: Math.sin(a), y: 0.16, z: -Math.cos(a) }\n}\n\n/**\n * The unlit part of the visible face: the dark arc of the body's own outline,\n * closed against the terminator's visible arc. Two curves that already meet at\n * the limb, so the region between them is the night side exactly — no binning,\n * and no spokes to the centre where a bin happened to miss the terminator.\n */\nfunction nightPath(\n  rim: readonly Vec2[],\n  dark: readonly boolean[],\n  edge: readonly Vec2[],\n): string {\n  const count = rim.length\n  if (dark.every((night) => night)) return polygonPath(rim)\n  if (!dark.some((night) => night) || edge.length < 3) return \"\"\n\n  // The longest unbroken run of dark bins: the one arc the light is not on.\n  let best = { start: 0, length: 0 }\n  for (let start = 0; start < count; start++) {\n    if (dark[start] && dark[(start - 1 + count) % count]) continue\n    let length = 0\n    while (length < count && dark[(start + length) % count]) length++\n    if (length > best.length) best = { start, length }\n  }\n  if (best.length < 2) return \"\"\n  const arc = Array.from({ length: best.length }, (_, step) => rim[(best.start + step) % count])\n\n  // The terminator comes back as a closed sweep with the far half removed, so\n  // it can be split across the seam: start it where its own gap is widest.\n  const term = openedAt(edge)\n  const tail = arc[arc.length - 1]\n  const forward =\n    span(tail, term[0]) <= span(tail, term[term.length - 1]) ? term : [...term].reverse()\n  return polygonPath([...arc, ...forward])\n}\n\nconst span = (a: Vec2, b: Vec2) => Math.hypot(a.x - b.x, a.y - b.y)\n\n/** A sampled arc rotated so it starts after its own widest gap. */\nfunction openedAt(points: readonly Vec2[]): Vec2[] {\n  if (points.length < 3) return [...points]\n  let seam = 0\n  let widest = -1\n  for (let index = 0; index < points.length; index++) {\n    const gap = span(points[index], points[(index + 1) % points.length])\n    if (gap > widest) {\n      widest = gap\n      seam = index + 1\n    }\n  }\n  return [...points.slice(seam), ...points.slice(0, seam)]\n}\n\nfunction polygonPath(points: readonly Vec2[]): string {\n  if (points.length < 3) return \"\"\n  return `${points.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")} Z`\n}\n\nfunction linePath(points: readonly Vec2[]): string {\n  if (points.length < 2) return \"\"\n  return points.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")\n}\n\nexport { CelestialAsteroid }\n",
      "type": "registry:ui",
      "target": "@ui/celestial-asteroid.tsx"
    }
  ],
  "categories": [
    "robotics",
    "space"
  ],
  "type": "registry:ui"
}