{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "robot-sunflower",
  "title": "Sunflower",
  "description": "A heliotropic collector mast: a golden-angle floret lattice on a dished head, aimed at the light by a two-axis tracker, on a stem that leans toward it while the gimbal collar takes up the remainder.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/phyllotaxis-geometry.json",
    "https://robocn.dev/r/spine-kinematics.json",
    "https://robocn.dev/r/use-robot-motion.json",
    "https://robocn.dev/r/use-pointer-target.json"
  ],
  "files": [
    {
      "path": "src/components/ui/robot-sunflower.tsx",
      "content": "\"use client\"\n\n/**\n * robot-sunflower — a heliotropic collector mast.\n *\n * The machine is one idea carried all the way through: the light is a\n * *direction*, and everything on the machine is placed in the frame that\n * direction implies. `aimFrom` turns the light into an azimuth and an\n * elevation, `trackerFrame` turns that pair back into the head's own axes, and\n * the disc, the rays and the leaf wings are all written in those axes — so\n * they cannot disagree about where the sun is.\n *\n * Two things fall out rather than being drawn. The florets are placed by the\n * golden angle over equal area, and the spiral arms are then *found* in the\n * result: consecutive Fibonacci numbers of them, because the angle says so.\n * And the collar at the top of the stem is a remainder — the stem leans toward\n * the light on its own, and the collar takes up exactly what the stem did not,\n * so the head's normal never comes off the sun however far the stem bends.\n *\n * One geometry, four cameras. Design note: docs/heliotropic-collector.md.\n */\n\nimport * as React from \"react\"\n\nimport { usePointerTarget } from \"@/hooks/use-pointer-target\"\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp, convexHull2, type Vec2, type Vec3 } from \"@/lib/robocn/kinematics\"\nimport {\n  aimDirection,\n  aimFrom,\n  discDish,\n  framePoint,\n  frameDirection,\n  parastichyOffsets,\n  rayFlorets,\n  spiralArm,\n  trackerFrame,\n  vogelDisc,\n  type TrackerAim,\n} from \"@/lib/robocn/phyllotaxis\"\nimport { solveSpine } from \"@/lib/robocn/spine\"\nimport {\n  aboutPoint,\n  capsulePath,\n  circleFootprint,\n  extrudedPath,\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_WIDTH = 200\nconst VIEW_HEIGHT = 210\n/** The centre of the anchor plate, on the ground, in view units. */\nconst ORIGIN = { x: 100, y: 188 }\n/** It is drawn straight on, looking at the face of a head aimed at noon. */\nconst NATIVE_VIEW: RobotView = \"front\"\n\n/** The mast, in world units. */\nconst STEM_LENGTH = 104\nconst STEM_SEGMENTS = 10\nconst STEM_RADIUS = 3.8\nconst ANCHOR_RADIUS = 20\nconst ANCHOR_HEIGHT = 6\n/** How far the head sits above the last stem joint, along the tip tangent. */\nconst NECK = 8\n/** The head. */\nconst HEAD_RADIUS = 33\nconst DISC_RADIUS = 28\nconst RAY_LENGTH = 19\nconst RAY_WIDTH = 8.6\nconst HUB_RADIUS = 6.5\n/** How far the rim leads the centre of the dished face. */\nconst DISH = 5.5\n/** Ray pitch at nothing open, and at fully open. */\nconst FURLED_PITCH = 72\nconst OPEN_PITCH = -8\n/** Where the leaf wings are bracketed, as a fraction of the stem from the root. */\nconst LEAF_STATIONS = [0.36, 0.62] as const\nconst LEAF_SPAN = 30\nconst LEAF_CHORD = 21\n/**\n * The stem's full heliotropic lean, in the spine solver's own −1..1 turn. Held\n * well short of the stop: past about a third the head leaves the frame, and a\n * mast that lies down is not what tracking the sun looks like.\n */\nconst MAX_LEAN = 0.13\n/** Daylight travelled per second while it returns to its behaviour. */\nconst DAY_RATE = 0.42\n/** Peak solar elevation at noon, in degrees. */\nconst NOON = 68\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/** How far the camera pulls back, and rides up, to hold one frame. */\nconst framing: Record<RobotView, { zoom: number; rise: number }> = {\n  plan: { zoom: 0.92, rise: -84 },\n  front: { zoom: 1, rise: 0 },\n  profile: { zoom: 1, rise: 0 },\n  iso: { zoom: 0.9, rise: -10 },\n}\n\nexport type SunflowerBehavior = \"sweep\" | \"day\" | \"nod\" | \"static\"\n\nexport interface RobotSunflowerProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  size?: RobotSize | number\n  variant?: RobotVariant\n  /** Where the camera stands. One machine, four projections. */\n  view?: RobotView\n  /** Controlled time of day, 0 and 1 midnight, 0.5 noon. Stops the loop. */\n  daylight?: number\n  /** Where the light actually is. Overrides the day arc entirely. */\n  sun?: TrackerAim\n  /** What the day does when `daylight` is not supplied. */\n  behavior?: SunflowerBehavior\n  /** Cycles per second: one pass of the arc. */\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  phase?: number\n  /** Drag across it to scrub the day, or arrow-key it. */\n  interactive?: boolean\n  onDaylightChange?: (daylight: number) => void\n  /** Controlled gaze in −1..1; overrides pointer tracking. */\n  look?: Vec2 | null\n  /** Hand the light to the pointer while it is over the drawing. */\n  track?: boolean\n  /** Collector cells on the face. Clamped 12–320. */\n  florets?: number\n  /** Ray petals round the rim. Clamped 0–48. */\n  rays?: number\n  /** Spiral arms drawn over the lattice. Clamped 0–24; 0 leaves them off. */\n  arms?: number\n  /** How open the rays are, 0 furled to 1 wide. Omit and the light opens them. */\n  bloom?: number\n  /** How much of the aim the stem takes up itself, 0 rigid to 1 full lean. */\n  lean?: number\n  signal?: \"idle\" | \"ready\" | \"warning\"\n  showGround?: boolean\n  label?: string\n}\n\nfunction RobotSunflower({\n  size = \"md\",\n  variant = \"solid\",\n  view = NATIVE_VIEW,\n  daylight,\n  sun,\n  behavior = \"sweep\",\n  speed = 0.14,\n  animate = true,\n  paused = false,\n  phase = 0,\n  interactive = false,\n  onDaylightChange,\n  look = null,\n  track = true,\n  florets = 120,\n  rays = 21,\n  arms = 8,\n  bloom,\n  lean = 1,\n  signal = \"ready\",\n  showGround = true,\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}: RobotSunflowerProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const controlled = daylight !== undefined\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n\n  const hold = controlled ? clamp(Number.isFinite(daylight) ? daylight : 0.5, 0, 1) : held\n  const goal = React.useCallback(\n    (clock: number) => sunflowerGoal(behavior, clock),\n    [behavior],\n  )\n  const motion = useRobotScalar(goal, {\n    rate: DAY_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 = clamp(Number.isFinite(next) ? next : 0.5, 0, 1)\n      setHeld(bounded)\n      onDaylightChange?.(bounded)\n    },\n    [onDaylightChange],\n  )\n\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit: Vec2) => apply(unit.x), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const pointer = usePointerTarget(svgRef, {\n    enabled: track && !look && !sun,\n    toWorld: React.useCallback(\n      (unit: Vec2) => ({ x: unit.x * 2 - 1, y: unit.y * 2 - 1 }),\n      [],\n    ),\n  })\n\n  const day = clamp(motion.value, 0, 1)\n  // The light, in order of authority: an explicit aim, then the pointer, then\n  // the day arc the behaviour is running.\n  const gaze = look ?? (dragging ? null : pointer.target)\n  const aim: TrackerAim = sun\n    ? {\n        azimuth: clamp(Number.isFinite(sun.azimuth) ? sun.azimuth : 0, -180, 180),\n        elevation: clamp(Number.isFinite(sun.elevation) ? sun.elevation : 0, -90, 90),\n      }\n    : gaze\n      ? {\n          azimuth: clamp(Number.isFinite(gaze.x) ? gaze.x : 0, -1, 1) * 110,\n          elevation: 34 - clamp(Number.isFinite(gaze.y) ? gaze.y : 0, -1, 1) * 46,\n        }\n      : sunflowerSun(day)\n\n  const openness =\n    bloom !== undefined\n      ? clamp(Number.isFinite(bloom) ? bloom : 0, 0, 1)\n      : sunflowerBloom(aim.elevation)\n  const cells = Math.round(clamp(Number.isFinite(florets) ? florets : 120, 12, 320))\n  const petals = Math.round(clamp(Number.isFinite(rays) ? rays : 21, 0, 48))\n  const armCount = Math.round(clamp(Number.isFinite(arms) ? arms : 8, 0, 24))\n  const bend = clamp(Number.isFinite(lean) ? lean : 1, 0, 1)\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  /** One collector cell: machined, so the cells read against the dark disc. */\n  const seed = robotSurface(\"metal\", variant, palette, 0.45)\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 towardCamera = (point: Vec3) => camera.depth(point.x, point.y, point.z)\n\n  /* ---- the stem ------------------------------------------------------- */\n\n  // The stem bends in the vertical plane that contains the light, so leaning\n  // toward a sun off to one side is a real lean and not a drawing of one.\n  const bearing = (aim.azimuth * Math.PI) / 180\n  const sideways: Vec3 = { x: Math.sin(bearing), y: 0, z: -Math.cos(bearing) }\n  // Low sun, hard lean: a head that has to look near the horizon gets there by\n  // bending the mast before it asks the collar for the rest.\n  const turn = bend * MAX_LEAN * Math.cos((clamp(aim.elevation, -90, 90) * Math.PI) / 180)\n  const spine = solveSpine({\n    segments: STEM_SEGMENTS,\n    length: STEM_LENGTH,\n    turn,\n    amplitude: 0.05,\n    waves: 0.7,\n    taper: -0.5,\n    phase: motion.clock * 0.35,\n  })\n  // Spine space has the nose at the origin running toward −x; the head is the\n  // nose, so `x + length` stands the mast up with its root on the floor.\n  const stemPoint = (joint: { position: Vec2 }): Vec3 => ({\n    x: sideways.x * joint.position.y,\n    y: joint.position.x + STEM_LENGTH,\n    z: sideways.z * joint.position.y,\n  })\n  const stem = spine.joints.map(stemPoint)\n  const tipAngle = (spine.head.angle * Math.PI) / 180\n  const tangent: Vec3 = {\n    x: sideways.x * Math.sin(tipAngle),\n    y: Math.cos(tipAngle),\n    z: sideways.z * Math.sin(tipAngle),\n  }\n  const crown = stem[0]\n  const headCentre: Vec3 = {\n    x: crown.x + tangent.x * NECK,\n    y: crown.y + tangent.y * NECK,\n    z: crown.z + tangent.z * NECK,\n  }\n\n  /* ---- the head ------------------------------------------------------- */\n\n  const frame = trackerFrame(aim)\n  const forward = frame.forward\n  const faceVisible = towardCamera(forward) > 0\n  // The collar is the remainder: what the stem did not take up, the gimbal did.\n  const collar =\n    (Math.acos(\n      clamp(\n        tangent.x * forward.x + tangent.y * forward.y + tangent.z * forward.z,\n        -1,\n        1,\n      ),\n    ) *\n      180) /\n    Math.PI\n\n  const place = (local: Vec3) => framePoint(frame, headCentre, local)\n  const sites = vogelDisc(cells, { radius: DISC_RADIUS, innerRadius: HUB_RADIUS * 0.7 })\n  const dishOptions = { dish: DISH, extent: DISC_RADIUS }\n  const cellRadius = Math.max(0.7, (DISC_RADIUS * 0.9) / Math.sqrt(cells))\n  const lattice = sites.map((site) => {\n    const surface = discDish(site, dishOptions)\n    const world = place({ x: site.position.x, y: site.position.y, z: surface.offset })\n    return {\n      index: site.index,\n      point: at(world),\n      facing: towardCamera(frameDirection(frame, surface.normal)) > 0.02,\n    }\n  })\n\n  const offsets = parastichyOffsets(sites)\n  const armStep = offsets[0] ?? 0\n  const spirals =\n    armCount > 0 && armStep > 0\n      ? Array.from({ length: Math.min(armCount, armStep) }, (_, index) => {\n          const start = Math.round((index * armStep) / Math.min(armCount, armStep))\n          return {\n            index,\n            path: linePath(\n              spiralArm(sites, start, armStep).map((site) =>\n                at(place({\n                  x: site.position.x,\n                  y: site.position.y,\n                  z: discDish(site, dishOptions).offset,\n                })),\n              ),\n            ),\n          }\n        })\n      : []\n\n  const petalPitch = FURLED_PITCH + (OPEN_PITCH - FURLED_PITCH) * openness\n  const ring = rayFlorets(petals, {\n    radius: HEAD_RADIUS - 2,\n    length: RAY_LENGTH,\n    width: RAY_WIDTH,\n    taper: 0.34,\n    pitch: petalPitch,\n  }).map((ray) => {\n    const corners = ray.corners.map(place)\n    const mid = place({\n      x: (ray.root.x + ray.tip.x) / 2,\n      y: (ray.root.y + ray.tip.y) / 2,\n      z: (ray.root.z + ray.tip.z) / 2,\n    })\n    return {\n      index: ray.index,\n      path: hullPath(corners.map(at)),\n      spine: linePath([at(place(ray.root)), at(place(ray.tip))]),\n      depth: towardCamera(mid),\n    }\n  })\n  const headDepth = towardCamera(headCentre)\n  const farRays = ring.filter((ray) => ray.depth <= headDepth)\n  const nearRays = ring.filter((ray) => ray.depth > headDepth)\n\n  const rim = hullPath(\n    Array.from({ length: 40 }, (_, index) => {\n      const angle = (index / 40) * Math.PI * 2\n      return at(\n        place({\n          x: Math.cos(angle) * HEAD_RADIUS,\n          y: Math.sin(angle) * HEAD_RADIUS,\n          z: DISH,\n        }),\n      )\n    }),\n  )\n\n  /* ---- the leaf wings -------------------------------------------------- */\n\n  // The leaves take half the aim: a panel bracketed off the mast does not have\n  // the travel the head's gimbal does, and it shows.\n  const leafAim: TrackerAim = { azimuth: aim.azimuth, elevation: aim.elevation * 0.5 }\n  const leafFrame = trackerFrame(leafAim)\n  const leaves = LEAF_STATIONS.map((station, index) => {\n    const joint = spine.joints[Math.round((1 - station) * STEM_SEGMENTS)] ?? spine.head\n    const mount = stemPoint(joint)\n    const hand = index % 2 === 0 ? 1 : -1\n    // A panel, not a leaf shape: a bracket runs out from the mast and the\n    // collector is a quad hung off it, so its outline is four corners in the\n    // world rather than a curve that has to be kept in step with the aim.\n    const corners = [\n      { x: hand * 9, y: -LEAF_CHORD * 0.42, z: 0 },\n      { x: hand * LEAF_SPAN, y: -LEAF_CHORD * 0.5, z: 0 },\n      { x: hand * LEAF_SPAN, y: LEAF_CHORD * 0.5, z: 0 },\n      { x: hand * 9, y: LEAF_CHORD * 0.42, z: 0 },\n    ].map((local) => framePoint(leafFrame, mount, local))\n    const root = framePoint(leafFrame, mount, { x: hand * 6, y: 0, z: 0 })\n    const tip = framePoint(leafFrame, mount, { x: hand * LEAF_SPAN, y: 0, z: 0 })\n    return {\n      side: hand > 0 ? (\"right\" as const) : (\"left\" as const),\n      path: hullPath(corners.map(at)),\n      stalk: capsulePath(at(mount), at(root), 1.8),\n      rib: linePath([at(root), at(tip)]),\n      depth: towardCamera(tip),\n      lit: towardCamera(leafFrame.forward) > 0,\n    }\n  })\n\n  /* ---- the ground ------------------------------------------------------ */\n\n  const anchor = extrudedPath(\n    circleFootprint(0, 0, ANCHOR_RADIUS, 3),\n    camera,\n    ANCHOR_HEIGHT,\n    0,\n  )\n  const feet = Array.from({ length: 3 }, (_, index) => {\n    const angle = (index / 3) * Math.PI * 2 + Math.PI / 2\n    return at({ x: Math.cos(angle) * ANCHOR_RADIUS, y: 1.4, z: Math.sin(angle) * ANCHOR_RADIUS })\n  })\n\n  const readout = Math.round(day * 100)\n  const hourLabel = `${String(Math.floor(day * 24)).padStart(2, \"0\")}:${String(\n    Math.floor(((day * 24) % 1) * 60),\n  ).padStart(2, \"0\")}`\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={`Robot sunflower, sun at ${Math.round(aim.elevation)} degrees elevation, ${viewNames[view] ?? viewNames.front}`}\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 100 : undefined}\n      aria-valuenow={interactive ? readout : undefined}\n      aria-valuetext={interactive ? `${hourLabel}` : 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 ? 0.1 : 0.03, 0.25)\n        if (delta !== 0) apply(day + delta)\n        else if (event.key === \"Home\") apply(0.25)\n        else if (event.key === \"End\") apply(0.75)\n        else return\n        event.preventDefault()\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!dragging) setHeld(null)\n      }}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_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\n            d={`M 12 ${ORIGIN.y} H 188 M ${ORIGIN.x} 12 V ${ORIGIN.y + 10}`}\n            strokeDasharray=\"2 3\"\n          />\n          {/* The aim itself: where the head says the light is. */}\n          <path\n            d={linePath([\n              at(headCentre),\n              at({\n                x: headCentre.x + forward.x * 46,\n                y: headCentre.y + forward.y * 46,\n                z: headCentre.z + forward.z * 46,\n              }),\n            ])}\n            strokeDasharray=\"4 2.5\"\n          />\n        </g>\n      )}\n\n      <g\n        data-frame\n        data-view={view}\n        transform={aboutPoint(\n          framing[view]?.rise ? `translate(0 ${framing[view].rise})` : \"\",\n          ORIGIN.x,\n          ORIGIN.y,\n          framing[view]?.zoom ?? 1,\n        )}\n      >\n        {showGround && (\n          <ellipse\n            cx={ORIGIN.x}\n            cy={ORIGIN.y}\n            rx={px(ANCHOR_RADIUS * 1.5)}\n            ry={px(Math.max(2.2, ANCHOR_RADIUS * 1.5 * camera.flatten))}\n            fill={palette.dark}\n            opacity={0.14}\n          />\n        )}\n\n        <g data-anchor>\n          <path d={anchor} {...cast} />\n          {feet.map((foot, index) => (\n            <circle key={index} cx={px(foot.x)} cy={px(foot.y)} r={3} {...machined} />\n          ))}\n        </g>\n\n        {leaves\n          .filter((leaf) => leaf.depth <= towardCamera(crown))\n          .map((leaf) => (\n            <LeafWing key={leaf.side} leaf={leaf} shell={shell} cast={cast} rib={palette.dark} />\n          ))}\n\n        <g data-stem>\n          {stem.slice(0, -1).map((joint, index) => (\n            <path\n              key={index}\n              d={capsulePath(at(joint), at(stem[index + 1]), STEM_RADIUS)}\n              {...shell}\n            />\n          ))}\n          {stem.map((joint, index) => (\n            <circle\n              key={index}\n              data-node={index}\n              cx={px(at(joint).x)}\n              cy={px(at(joint).y)}\n              r={px(STEM_RADIUS * 0.6)}\n              fill={palette.dark}\n              opacity={variant === \"solid\" ? 0.4 : 0.22}\n            />\n          ))}\n        </g>\n\n        {leaves\n          .filter((leaf) => leaf.depth > towardCamera(crown))\n          .map((leaf) => (\n            <LeafWing key={leaf.side} leaf={leaf} shell={shell} cast={cast} rib={palette.dark} />\n          ))}\n\n        <g data-collar>\n          <path d={capsulePath(at(crown), at(headCentre), 3.6)} {...machined} />\n          <circle cx={px(at(crown).x)} cy={px(at(crown).y)} r={4.4} {...cast} />\n        </g>\n\n        <g data-head>\n          {farRays.map((ray) => (\n            <path key={ray.index} data-ray={ray.index} d={ray.path} {...shell} />\n          ))}\n\n          <path data-rim d={rim} {...(faceVisible ? cast : shell)} />\n\n          {faceVisible ? (\n            <g data-disc>\n              {spirals.map((arm) => (\n                <path\n                  key={arm.index}\n                  data-arm={arm.index}\n                  d={arm.path}\n                  fill=\"none\"\n                  stroke={palette.metal}\n                  strokeWidth={0.6}\n                  opacity={0.4}\n                />\n              ))}\n              {lattice\n                .filter((cell) => cell.facing)\n                .map((cell) => (\n                  <circle\n                    key={cell.index}\n                    data-floret={cell.index}\n                    cx={px(cell.point.x)}\n                    cy={px(cell.point.y)}\n                    r={px(cellRadius)}\n                    {...seed}\n                    opacity={0.55}\n                  />\n                ))}\n              <circle\n                data-hub\n                cx={px(at(headCentre).x)}\n                cy={px(at(headCentre).y)}\n                r={HUB_RADIUS}\n                {...machined}\n              />\n              <circle\n                cx={px(at(headCentre).x)}\n                cy={px(at(headCentre).y)}\n                r={px(HUB_RADIUS * 0.45)}\n                fill={signalColor}\n                opacity={0.9}\n              />\n            </g>\n          ) : (\n            // Turned away: the back of the disc, and the ribs that carry it.\n            <g data-back>\n              {Array.from({ length: 6 }, (_, index) => {\n                const angle = (index / 6) * Math.PI\n                return (\n                  <path\n                    key={index}\n                    d={linePath([\n                      at(place({ x: Math.cos(angle) * HEAD_RADIUS, y: Math.sin(angle) * HEAD_RADIUS, z: DISH })),\n                      at(place({ x: -Math.cos(angle) * HEAD_RADIUS, y: -Math.sin(angle) * HEAD_RADIUS, z: DISH })),\n                    ])}\n                    fill=\"none\"\n                    stroke={palette.dark}\n                    strokeWidth={0.9}\n                    opacity={0.45}\n                  />\n                )\n              })}\n              <circle\n                data-hub\n                cx={px(at(headCentre).x)}\n                cy={px(at(headCentre).y)}\n                r={px(HUB_RADIUS * 1.2)}\n                {...cast}\n              />\n            </g>\n          )}\n\n          {nearRays.map((ray) => (\n            <path key={ray.index} data-ray={ray.index} d={ray.path} {...shell} />\n          ))}\n        </g>\n\n        <circle\n          data-lamp\n          cx={px(at({ x: 0, y: ANCHOR_HEIGHT + 3, z: -ANCHOR_RADIUS * 0.7 }).x)}\n          cy={px(at({ x: 0, y: ANCHOR_HEIGHT + 3, z: -ANCHOR_RADIUS * 0.7 }).y)}\n          r={2.2}\n          fill={signalColor}\n          className={signal === \"ready\" ? \"robocn-pulse\" : undefined}\n        />\n      </g>\n\n      {variant === \"blueprint\" && (\n        <text\n          x={188}\n          y={24}\n          textAnchor=\"end\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={5}\n          fill={palette.grid}\n        >\n          {`AZ ${px(aim.azimuth)}° EL ${px(aim.elevation)}° COLLAR ${px(collar)}°`}\n        </text>\n      )}\n      {label && (\n        <text\n          x={ORIGIN.x}\n          y={204}\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\ninterface Leaf {\n  side: \"left\" | \"right\"\n  path: string\n  stalk: string\n  rib: string\n  lit: boolean\n}\n\nfunction LeafWing({\n  leaf,\n  shell,\n  cast,\n  rib,\n}: {\n  leaf: Leaf\n  shell: ReturnType<typeof robotSurface>\n  cast: ReturnType<typeof robotSurface>\n  rib: string\n}) {\n  return (\n    <g data-leaf={leaf.side}>\n      <path d={leaf.stalk} {...cast} />\n      {/* Washed out when the panel is edge-on to the light, which is the only\n          thing a collector facing the wrong way has to say. */}\n      <path d={leaf.path} {...shell} fillOpacity={leaf.lit ? shell.fillOpacity : 0.5} />\n      <path d={leaf.rib} fill=\"none\" stroke={rib} strokeWidth={0.7} opacity={0.5} />\n    </g>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* behaviour                                                                   */\n/* -------------------------------------------------------------------------- */\n\n/** Where in the day the machine is aiming to be at `clock`, 0..1. */\nexport function sunflowerGoal(behavior: SunflowerBehavior, clock: number): number {\n  if (behavior === \"static\") return 0.5\n  const t = Number.isFinite(clock) ? ((clock % 1) + 1) % 1 : 0\n  switch (behavior) {\n    // The working arc: a two-axis mount has an azimuth range, and this one\n    // covers the part of the day a collector actually collects in — the head\n    // stays on the light without the mast having to lie down to reach it.\n    case \"sweep\":\n      return 0.34 + 0.32 * t\n    // The whole twenty-four hours, so the head turns away and the rays furl.\n    case \"day\":\n      return t\n    // Hunting about noon, the way a tracker that has arrived behaves.\n    case \"nod\":\n      return 0.5 + 0.035 * Math.sin(t * Math.PI * 2)\n    default:\n      return 0.5\n  }\n}\n\n/**\n * The sun for a time of day: round once in azimuth, and an elevation that is a\n * sine about the horizon, so midnight is as far below it as noon is above.\n * This is a shaped number and not a solar position — there is no date here and\n * no latitude.\n */\nexport function sunflowerSun(daylight: number): TrackerAim {\n  const t = clamp(Number.isFinite(daylight) ? daylight : 0.5, 0, 1)\n  return {\n    azimuth: -180 + 360 * t,\n    elevation: NOON * Math.sin(2 * Math.PI * (t - 0.25)),\n  }\n}\n\n/** How far open the rays are for a given solar elevation: shut below the horizon. */\nexport function sunflowerBloom(elevation: number): number {\n  const e = clamp(Number.isFinite(elevation) ? elevation : 0, -90, 90)\n  return clamp(e / 40, 0, 1)\n}\n\n/** The direction the light is coming from, for a given aim. */\nexport const sunflowerLight = (aim: TrackerAim): Vec3 => aimDirection(aim)\n\n/** The aim a direction implies — the inverse, and the same one the head uses. */\nexport const sunflowerAim = (direction: Vec3): TrackerAim => aimFrom(direction)\n\n/* -------------------------------------------------------------------------- */\n/* drawing helpers                                                             */\n/* -------------------------------------------------------------------------- */\n\n/** The outline round a set of projected points: any solid, from any angle. */\nfunction hullPath(points: readonly Vec2[]): string {\n  const hull = convexHull2(points)\n  if (hull.length < 3) return \"\"\n  return `${hull.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")} Z`\n}\n\n/** An open polyline: a spiral arm, a rib, a dimension line. */\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 { RobotSunflower }\n",
      "type": "registry:ui",
      "target": "@ui/robot-sunflower.tsx"
    }
  ],
  "categories": [
    "robotics",
    "garden"
  ],
  "type": "registry:ui"
}