{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animatronic-face",
  "title": "Animatronic face",
  "description": "An expressive humanoid head driven as servo channels: paired brows, lids, cheeks and lip corners, a hinged jaw, nine blendable expressions, pointer-tracked gaze, and drawable push-rods. One ellipsoid geometry, four camera angles.",
  "registryDependencies": [
    "https://robocn.dev/r/face-actuation.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/use-pointer-target.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/animatronic-face.tsx",
      "content": "\"use client\"\n\n/**\n * animatronic-face — an expressive humanoid head, driven as servo channels.\n *\n * The set already has a flat mood panel (`robot-face`) and a companion head on\n * a solved platform (`reachy-mini`). This is the third and the expressive one:\n * brows, lids, cheeks, nose, lip corners and jaw are each a servo, and an\n * expression is a blend of their targets rather than a swap of artwork. Nothing\n * below branches on an expression name — the drawing reads channels.\n *\n * The head is one geometry. The skull is an ellipsoid whose silhouette projects\n * to an exact ellipse; every feature is a curve drawn in the face's own chart\n * and pushed onto that surface, so the brow wraps the temple and the far eye\n * turns away on its own. Four cameras, no per-angle artwork.\n *\n * Design note: `docs/animatronic-face.md`.\n */\n\nimport * as React from \"react\"\n\nimport { usePointerTarget } from \"@/hooks/use-pointer-target\"\nimport { useRobotClock } from \"@/hooks/use-robot-motion\"\nimport { clamp, type Vec2, type Vec3 } from \"@/lib/robocn/kinematics\"\nimport {\n  blendFace,\n  defaultHeadGeometry,\n  ellipsoidOutline,\n  faceShape,\n  onFace,\n  restPose,\n  rotateHead,\n  solveFace,\n  type FaceChannels,\n  type FaceExpression,\n  type FaceSide,\n  type HeadGeometry,\n  type HeadPose,\n} from \"@/lib/robocn/face\"\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 NATIVE_VIEW: RobotView = \"front\"\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/** Where the face sits on the skull, in the chart the artwork is drawn in. */\nconst EYE_X = 13\nconst EYE_Y = 12\n/** The eyeball, not a socket: the recessed band around it is the socket. */\nconst EYE_R = 7\nconst BROW_Y = 27\nconst MOUTH_Y = -24\n/** The jaw hinges on a real axis through the ear servos. */\nconst HINGE: Vec3 = { x: 0, y: 4, z: 18 }\nconst JAW_SWING = 15\n\nexport type AnimatronicBehavior = \"idle\" | \"converse\" | \"listen\" | \"emote\" | \"static\"\n\nexport interface AnimatronicFaceProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Which expression the rig drives toward. Omit and the behaviour picks. */\n  expression?: FaceExpression\n  /** How far it drives there, 0..1. */\n  intensity?: number\n  /** What the head does with anything you have not supplied. */\n  behavior?: AnimatronicBehavior\n  /** Cycles per second. */\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  /** Seconds of offset, so a row of heads breaks step. */\n  phase?: number\n  /** Lid closure on top of the expression, 0..1. Omit and it blinks on its own. */\n  blink?: number\n  /** Speech level, 0..1: opens the jaw and works the lips. */\n  speech?: number\n  /** Neck angles in degrees. Omit and the head turns toward the pointer. */\n  yaw?: number\n  pitch?: number\n  roll?: number\n  /** Pupil aim in −1..1 on both axes. Set it to drive the gaze yourself. */\n  look?: Vec2 | null\n  /** Follow the pointer anywhere on the page while `look` is null. */\n  track?: boolean\n  /** Turn the head toward the pointer, and react when clicked. */\n  interactive?: boolean\n  onReact?: () => void\n  /** Drive individual servos. These win over the expression. */\n  channels?: Partial<FaceChannels> & { left?: Partial<FaceSide>; right?: Partial<FaceSide> }\n  /** Draw the push-rods behind the face. */\n  showActuators?: boolean\n  showNeck?: boolean\n  showGround?: boolean\n  /** Where the camera stands. One head, four projections. */\n  view?: RobotView\n  size?: RobotSize | number\n  variant?: RobotVariant\n  label?: string\n  geometry?: Partial<HeadGeometry>\n}\n\nfunction AnimatronicFace({\n  expression,\n  intensity,\n  behavior = \"idle\",\n  speed = 0.3,\n  animate = true,\n  paused = false,\n  phase = 0,\n  blink,\n  speech,\n  yaw,\n  pitch,\n  roll,\n  look = null,\n  track = true,\n  interactive = true,\n  onReact,\n  channels,\n  showActuators = false,\n  showNeck = true,\n  showGround = true,\n  view = NATIVE_VIEW,\n  size = \"md\",\n  variant = \"solid\",\n  label,\n  geometry,\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  onPointerDown,\n  ...props\n}: AnimatronicFaceProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const shell = robotSurface(\"shell\", variant, palette, 1.4)\n  const plate = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n\n  const rig: HeadGeometry = { ...defaultHeadGeometry, ...geometry, radii: { ...defaultHeadGeometry.radii, ...geometry?.radii } }\n  const radii = rig.radii\n\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const clock = useRobotClock({\n    speed,\n    animate: animate && behavior !== \"static\",\n    paused,\n    phase,\n  })\n\n  const pointer = usePointerTarget(svgRef, {\n    enabled: (track || interactive) && !look,\n    within: \"window\",\n    persist: true,\n    toWorld: React.useCallback((point: Vec2) => {\n      const x = (point.x - 0.5) * 2\n      const y = (point.y - 0.5) * 2\n      const distance = Math.hypot(x, y) || 1\n      const limit = Math.min(1, distance) / distance\n      return { x: x * limit, y: y * limit }\n    }, []),\n  })\n\n  // A poke is one damped swing: the head starts, blinks, and warms up. It rides\n  // the same decaying ring the rest of the set uses.\n  const [poked, setPoked] = React.useState<number | null>(null)\n  const since = poked === null ? Infinity : (clock - poked) / Math.max(speed, 0.01)\n  const react = since >= 0 && since < 1.4 ? Math.exp(-since * 2.6) : 0\n  const startle = react * Math.cos(since * 9)\n\n  const drive = faceBehaviorDrive(behavior, clock)\n  const watching = (track || interactive) && !look ? pointer.target : null\n  const gaze = look ?? watching ?? drive.gaze\n\n  // A reaction warms whatever the face is already doing toward joy, rather than\n  // replacing it, so a poke mid-sentence still reads as the same head.\n  const base: FaceExpression | FaceChannels = expression ?? drive.expression\n  const shape: FaceExpression | FaceChannels =\n    react > 0.01\n      ? blendFace(typeof base === \"string\" ? faceShape(base) : base, faceShape(\"joy\"), react * 0.7)\n      : base\n\n  const solution = solveFace(\n    {\n      expression: shape,\n      intensity: intensity ?? drive.intensity,\n      gaze,\n      blink: blink ?? Math.max(drive.blink, react > 0.15 && since < 0.35 ? 1 : 0),\n      speech: speech ?? drive.speech,\n      channels,\n    },\n    rig,\n  )\n\n  const pose: HeadPose = {\n    yaw: bound(yaw ?? (watching ? watching.x * 26 : drive.pose.yaw), 34),\n    pitch: bound((pitch ?? (watching ? -watching.y * 16 : drive.pose.pitch)) + startle * 5, 28),\n    roll: bound((roll ?? drive.pose.roll) + startle * 3, 26),\n  }\n  const offset: Vec3 = { x: 0, y: drive.pose.heave - react * 2.2, z: 0 }\n\n  const camera = robotCamera(view)\n  // The camera is linear, so its coefficients are the direction toward it —\n  // which is all the visibility test needs.\n  const toCamera = {\n    x: camera.depth(1, 0, 0),\n    y: camera.depth(0, 1, 0),\n    z: camera.depth(0, 0, 1),\n  }\n\n  const toScreen = (point: Vec3): Vec2 => {\n    const turned = rotateHead(point, pose)\n    return camera.project(turned.x + offset.x, turned.y + offset.y, turned.z + offset.z)\n  }\n  /** Chart coordinates -> a point on the skull -> the screen. */\n  const skin = (u: number, v: number, outset = 0) => onFace(u, v, radii, outset)\n  const chart = (point: Vec2, outset = 0) => toScreen(skin(point.x, point.y, outset))\n\n  /**\n   * The jaw is a hinge: its points swing about the ear axis before anything\n   * else touches them. The axis sits behind and above the mouth, so opening\n   * carries the whole plate — and the lower lip with it — down and back.\n   */\n  const swing = -(solution.jaw * JAW_SWING * Math.PI) / 180\n  const hinged = (point: Vec3): Vec3 => {\n    const y = point.y - HINGE.y\n    const z = point.z - HINGE.z\n    const c = Math.cos(swing)\n    const s = Math.sin(swing)\n    return { x: point.x, y: HINGE.y + y * c - z * s, z: HINGE.z + y * s + z * c }\n  }\n  const jawChart = (point: Vec2, outset = 0) => toScreen(hinged(skin(point.x, point.y, outset)))\n\n  const trace = (points: Vec2[], project: (p: Vec2, outset?: number) => Vec2, outset = 0, close = true) => {\n    const path = points\n      .map((point, index) => {\n        const screen = project(point, outset)\n        return `${index ? \"L\" : \"M\"} ${px(screen.x)} ${px(screen.y)}`\n      })\n      .join(\" \")\n    return close ? `${path} Z` : path\n  }\n\n  /** The same, for a part that leaves the surface — the nose has volume. */\n  const trace3 = (points: Vec3[], close = true) => {\n    const path = points\n      .map((point, index) => {\n        const screen = toScreen(point)\n        return `${index ? \"L\" : \"M\"} ${px(screen.x)} ${px(screen.y)}`\n      })\n      .join(\" \")\n    return close ? `${path} Z` : path\n  }\n\n  /**\n   * How squarely a patch of the face meets the camera. The ellipsoid's normal\n   * against the view direction, so the far eye turns away by itself and the\n   * whole face fades out looking straight down at the crown.\n   */\n  const facing = (u: number, v: number) => {\n    const point = skin(u, v)\n    const normal = { x: point.x / radii.x ** 2, y: point.y / radii.y ** 2, z: point.z / radii.z ** 2 }\n    const length = Math.hypot(normal.x, normal.y, normal.z) || 1\n    const turned = rotateHead(\n      { x: normal.x / length, y: normal.y / length, z: normal.z / length },\n      pose,\n    )\n    return turned.x * toCamera.x + turned.y * toCamera.y + turned.z * toCamera.z\n  }\n  const seen = (u: number, v: number) => px(clamp((facing(u, v) - 0.02) * 5, 0, 1))\n  /**\n   * Centreline parts — nose, mouth, jaw — sit on the median plane, so the\n   * normal test is the wrong question for them: seen from the side they are not\n   * turned away, they are edge-on, and the projection already collapses them to\n   * a line. They fade only once the head has actually turned its back.\n   */\n  const forward = rotateHead({ x: 0, y: 0, z: -1 }, pose)\n  const ahead = forward.x * toCamera.x + forward.y * toCamera.y + forward.z * toCamera.z\n  /** The nose has volume, so it survives being seen edge-on. */\n  const median = px(clamp((ahead + 0.25) * 3, 0, 1))\n  /** The mouth and the jaw are flat on the face, so edge-on they are a trace. */\n  const medianFlat = px(clamp(ahead * 2.2 + 0.12, 0, 1))\n\n  const skull = ellipsoidOutline(radii, pose, camera, offset)\n  const ring = (cx: number, cy: number, rx: number, ry: number, count = 22, from = 0, sweep = Math.PI * 2) =>\n    Array.from({ length: count }, (_, index) => {\n      const angle = from + (index / (count - 1 || 1)) * sweep\n      return { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry }\n    })\n\n  /**\n   * A rounded panel in the face chart. Corners are real arcs, so the panel\n   * wraps the skull instead of creasing when the head turns.\n   */\n  const panel = (cx: number, cy: number, halfW: number, halfH: number, corner: number, per = 5) => {\n    const r = Math.max(0, Math.min(corner, halfW, halfH))\n    const centers: [number, number, number][] = [\n      [cx + halfW - r, cy + halfH - r, 0],\n      [cx - halfW + r, cy + halfH - r, Math.PI / 2],\n      [cx - halfW + r, cy - halfH + r, Math.PI],\n      [cx + halfW - r, cy - halfH + r, (Math.PI * 3) / 2],\n    ]\n    return centers.flatMap(([ox, oy, from]) =>\n      Array.from({ length: per }, (_, index) => {\n        const angle = from + (index / (per - 1)) * (Math.PI / 2)\n        return { x: ox + Math.cos(angle) * r, y: oy + Math.sin(angle) * r }\n      }),\n    )\n  }\n\n  /**\n   * The eye band: a recess milled across the face. It is what makes the lids\n   * work — a lid painted in the band's own colour is invisible while the eye is\n   * open and reads as a shutter the moment it starts to close.\n   */\n  const band = (\n    <path data-band opacity={seen(0, EYE_Y)} d={trace(panel(0, EYE_Y, 26, 9.5, 8, 6), chart, 0.25)} {...cast} />\n  )\n\n  const eye = (sign: -1 | 1) => {\n    const channel = sign < 0 ? solution.left : solution.right\n    const cx = sign * EYE_X\n    // A lid closes from above the eye to just past its centre; retracted, it\n    // sits clear of the ball, which is what widens a surprised eye.\n    const lidTop = EYE_Y + EYE_R * 1.05 - channel.lidUpper * (EYE_R * 2.1)\n    const lidBottom = EYE_Y - EYE_R * 1.05 + channel.lidLower * (EYE_R * 1.35)\n    const pupil = { x: cx + solution.gaze.x * 3.2, y: EYE_Y - solution.gaze.y * 2.8 }\n    const lid = (name: string, edge: number, over: boolean) => (\n      <g data-lid={name}>\n        <path\n          d={trace(\n            [\n              { x: cx - EYE_R * 1.16, y: edge },\n              ...ring(cx, EYE_Y, EYE_R * 1.16, EYE_R * 1.3, 12, Math.PI, over ? -Math.PI : Math.PI),\n              { x: cx + EYE_R * 1.16, y: edge },\n            ],\n            chart,\n            1.9,\n          )}\n          fill={variant === \"solid\" ? palette.dark : \"none\"}\n          stroke={variant === \"solid\" ? \"none\" : palette.grid}\n          strokeWidth={0.7}\n        />\n        {/* The lash line: only the closing edge is drawn, and only once the\n            lid has actually come over the ball. */}\n        <path\n          d={trace([{ x: cx - EYE_R * 1.1, y: edge }, { x: cx, y: edge + (over ? -0.9 : 0.9) }, { x: cx + EYE_R * 1.1, y: edge }], chart, 2.1, false)}\n          fill=\"none\"\n          stroke={palette.metal}\n          strokeWidth={0.9}\n          strokeLinecap=\"round\"\n          opacity={px(clamp((over ? channel.lidUpper : channel.lidLower) * 2 - 0.2, 0, 0.7))}\n        />\n      </g>\n    )\n    return (\n      <g key={sign} data-eye={sign < 0 ? \"left\" : \"right\"} opacity={seen(cx, EYE_Y)}>\n        <path d={trace(ring(cx, EYE_Y, EYE_R, EYE_R), chart, 0.9)} fill={palette.metal} stroke={palette.dark} strokeWidth={0.7} opacity={variant === \"solid\" ? 1 : 0.35} />\n        <path d={trace(ring(pupil.x, pupil.y, 4.2, 4.2, 18), chart, 1.3)} fill={palette.accent} stroke=\"none\" opacity={0.92} />\n        <path d={trace(ring(pupil.x, pupil.y, 2, 2, 14), chart, 1.5)} fill={palette.dark} stroke=\"none\" />\n        <path d={trace(ring(pupil.x + 1.5, pupil.y + 1.7, 1, 1, 10), chart, 1.7)} fill={palette.metal} stroke=\"none\" opacity={0.9} />\n        {lid(sign < 0 ? \"left-upper\" : \"right-upper\", lidTop, true)}\n        {lid(sign < 0 ? \"left-lower\" : \"right-lower\", lidBottom, false)}\n      </g>\n    )\n  }\n\n  const brow = (sign: -1 | 1) => {\n    const channel = sign < 0 ? solution.left : solution.right\n    const inner = BROW_Y + channel.browInner * 6\n    const outer = BROW_Y + channel.browOuter * 7\n    const points = Array.from({ length: 6 }, (_, index) => {\n      const t = index / 5\n      return {\n        x: sign * (5 + t * 20),\n        // A brow is an arch, so the middle rides above the chord between the tips.\n        y: inner + (outer - inner) * t + Math.sin(t * Math.PI) * 2,\n      }\n    })\n    return (\n      <path\n        key={sign}\n        data-brow={sign < 0 ? \"left\" : \"right\"}\n        opacity={seen(sign * 15, BROW_Y)}\n        d={trace(points, chart, 2.2, false)}\n        fill=\"none\"\n        stroke={palette.dark}\n        strokeWidth={3.4}\n        strokeLinecap=\"round\"\n      />\n    )\n  }\n\n  /**\n   * The cheek is a crease, not a pad: the fold that deepens beside the nose\n   * when the plate under it lifts. A line does what a panel could not — it\n   * reads as the face moving rather than as something stuck on it.\n   */\n  const cheek = (sign: -1 | 1) => {\n    const channel = sign < 0 ? solution.left : solution.right\n    const lift = channel.cheek\n    const points = [\n      { x: sign * (6.5 + lift * 0.8), y: -12 + lift * 2 },\n      { x: sign * (11 + lift * 2.2), y: -18 + lift * 1.6 },\n      { x: sign * (13.5 + lift * 2.6), y: -25 + lift * 1.2 },\n    ]\n    return (\n      <path\n        key={sign}\n        data-cheek={sign < 0 ? \"left\" : \"right\"}\n        opacity={px(clamp(seen(sign * 11, -18) * (0.18 + lift * 0.62), 0, 1))}\n        d={trace(points, chart, 0.8, false)}\n        fill=\"none\"\n        stroke={palette.dark}\n        strokeWidth={1.5 + lift}\n        strokeLinecap=\"round\"\n      />\n    )\n  }\n\n  // The nose is the one feature with volume: a wedge standing off the skull, so\n  // it breaks the silhouette in profile instead of being a line that vanishes.\n  // Two facets, one lit and one shadowed, from the same three points.\n  const noseLift = solution.noseWrinkle * 2.6\n  const bridge = skin(0, -1.5 + noseLift, 0.5)\n  const tip: Vec3 = { x: 0, y: -10.5 + noseLift, z: -(radii.z * 0.97 + 6) }\n  const wing = (sign: -1 | 1): Vec3 => {\n    const seat = skin(sign * 4.2, -13.6 + noseLift, 1)\n    return { ...seat, z: seat.z - 1.5 }\n  }\n  const nose = (\n    <g data-nose opacity={median}>\n      <path d={trace3([bridge, wing(-1), tip])} fill={palette.dark} stroke=\"none\" opacity={variant === \"solid\" ? 0.35 : 0} />\n      <path d={trace3([bridge, tip, wing(1)])} fill={palette.shell} stroke={palette.dark} strokeWidth={0.7} opacity={variant === \"solid\" ? 1 : 0} />\n      <path d={trace3([bridge, wing(-1), tip, wing(1)])} fill=\"none\" stroke={palette.dark} strokeWidth={1.1} opacity={0.75} />\n      {[-1, 1].map((nostril) => (\n        <path key={nostril} d={trace(ring(nostril * 2.9, -14.6 + noseLift, 1.1, 0.7, 10), chart, 1.6)} fill={palette.dark} stroke=\"none\" opacity={0.85} />\n      ))}\n      {solution.noseWrinkle > 0.15 &&\n        [0, 1].map((line) => (\n          <path\n            key={line}\n            d={trace([{ x: -6.5 - line * 2, y: -4 + line * 3 + noseLift }, { x: -3.2 - line, y: -2 + line * 3 + noseLift }], chart, 1.7, false)}\n            fill=\"none\"\n            stroke={palette.dark}\n            strokeWidth={1}\n            opacity={solution.noseWrinkle * 0.8}\n          />\n        ))}\n    </g>\n  )\n\n  // The corners are their own servos, so a lopsided mouth costs nothing. The\n  // cavity is cut into the skull and the jaw plate covers it, so the mouth\n  // opens because the hinge moved the plate off it — not because a second\n  // mouth was drawn.\n  const halfWidth = 13 - solution.lipPucker * 4\n  const cornerLeft = { x: -halfWidth, y: MOUTH_Y + solution.left.lipCorner * 5 }\n  const cornerRight = { x: halfWidth, y: MOUTH_Y + solution.right.lipCorner * 5 }\n  const upperLip = [\n    cornerLeft,\n    { x: -halfWidth * 0.45, y: MOUTH_Y + 2.2 - solution.lipPress * 1.3 },\n    { x: 0, y: MOUTH_Y + 1 - solution.lipPress * 1.1 },\n    { x: halfWidth * 0.45, y: MOUTH_Y + 2.2 - solution.lipPress * 1.3 },\n    cornerRight,\n  ]\n  // Deep enough that the plate is still in front of it at full swing, so the\n  // gap that opens is the cavity rather than the back of the head.\n  const cavity = [\n    ...upperLip,\n    { x: halfWidth * 0.92, y: MOUTH_Y - 6 },\n    { x: halfWidth * 0.62, y: MOUTH_Y - 12 },\n    { x: 0, y: MOUTH_Y - 14 },\n    { x: -halfWidth * 0.62, y: MOUTH_Y - 12 },\n    { x: -halfWidth * 0.92, y: MOUTH_Y - 6 },\n  ]\n  const lowerLip = [\n    cornerRight,\n    { x: halfWidth * 0.45, y: MOUTH_Y - 2.4 + solution.lipPress * 1.5 },\n    { x: 0, y: MOUTH_Y - 3.2 + solution.lipPress * 1.7 },\n    { x: -halfWidth * 0.45, y: MOUTH_Y - 2.4 + solution.lipPress * 1.5 },\n    cornerLeft,\n  ]\n\n  /** The jaw plate. Its top edge runs under the upper lip when it is shut. */\n  const jawOutline = [\n    { x: -26, y: -12 },\n    { x: -19, y: -21 },\n    { x: -9, y: -25.4 },\n    { x: 0, y: -26.6 },\n    { x: 9, y: -25.4 },\n    { x: 19, y: -21 },\n    { x: 26, y: -12 },\n    { x: 29.5, y: -17 },\n    { x: 27, y: -26 },\n    { x: 20.5, y: -31 },\n    { x: 12, y: -35.5 },\n    { x: 0, y: -37.6 },\n    { x: -12, y: -35.5 },\n    { x: -20.5, y: -31 },\n    { x: -27, y: -26 },\n    { x: -29.5, y: -17 },\n  ]\n  /** The seam the plate parts along — the top edge alone, drawn heavier. */\n  const jawSeam = jawOutline.slice(0, 7)\n\n  const mouth = (\n    <g data-mouth opacity={medianFlat}>\n      <path data-cavity d={trace(cavity, chart, 0.15)} {...cast} />\n      <path data-jaw d={trace(jawOutline, jawChart, 0.55)} {...shell} />\n      <path d={trace(jawSeam, jawChart, 0.75, false)} fill=\"none\" stroke={palette.dark} strokeWidth={0.9} strokeLinecap=\"round\" opacity={0.22} />\n      <path d={trace(upperLip, chart, 1.2, false)} fill=\"none\" stroke={palette.dark} strokeWidth={2.1} strokeLinecap=\"round\" opacity={px(1 - solution.jaw * 0.9)} />\n      <path d={trace(upperLip, chart, 1.3, false)} fill=\"none\" stroke={palette.metal} strokeWidth={1.8} strokeLinecap=\"round\" opacity={px(solution.jaw * 0.9)} />\n      <path d={trace(lowerLip, jawChart, 1.2, false)} fill=\"none\" stroke={palette.dark} strokeWidth={1.8} strokeLinecap=\"round\" opacity={0.7} />\n    </g>\n  )\n\n\n  /** One push-rod per servo, from the frame ring to the part it drives. */\n  const anchors: Record<string, Vec2> = {\n    \"left.browInner\": { x: -6, y: BROW_Y },\n    \"left.browOuter\": { x: -26, y: BROW_Y + 2 },\n    \"left.lidUpper\": { x: -EYE_X, y: EYE_Y + EYE_R },\n    \"left.lidLower\": { x: -EYE_X, y: EYE_Y - EYE_R },\n    \"left.cheek\": { x: -11, y: -18 },\n    \"left.lipCorner\": cornerLeft,\n    \"right.browInner\": { x: 6, y: BROW_Y },\n    \"right.browOuter\": { x: 26, y: BROW_Y + 2 },\n    \"right.lidUpper\": { x: EYE_X, y: EYE_Y + EYE_R },\n    \"right.lidLower\": { x: EYE_X, y: EYE_Y - EYE_R },\n    \"right.cheek\": { x: 11, y: -18 },\n    \"right.lipCorner\": cornerRight,\n    noseWrinkle: { x: 0, y: -10 + noseLift },\n    lipPress: { x: 0, y: MOUTH_Y + 1 },\n    lipPucker: { x: 0, y: MOUTH_Y - 4 },\n    jaw: { x: 0, y: -31 },\n  }\n  const rods = solution.actuators\n    .map((actuator) => {\n      const anchor = anchors[actuator.id]\n      if (!anchor) return null\n      const tip = actuator.id === \"jaw\" ? jawChart(anchor, 0.5) : chart(anchor, 0.5)\n      const frame = toScreen(\n        rotateHead({ x: anchor.x * 0.72, y: anchor.y * 0.72, z: radii.z * 0.45 }, restPose),\n      )\n      return { actuator, tip, frame }\n    })\n    .filter((rod): rod is NonNullable<typeof rod> => rod !== null)\n\n  const housing = (sign: -1 | 1) => {\n    const anchor = rotateHead({ x: sign * radii.x * 0.97, y: -2, z: 13 }, pose)\n    const center = { x: anchor.x + offset.x, y: anchor.y + offset.y, z: anchor.z + offset.z }\n    const shape = ellipsoidOutline({ x: 3.5, y: 8, z: 8 }, pose, camera, center)\n    const core = ellipsoidOutline({ x: 3.5, y: 4, z: 4 }, pose, camera, center)\n    const depth = camera.depth(anchor.x, anchor.y, anchor.z) - camera.depth(offset.x, offset.y, offset.z)\n    const turn = `rotate(${px(shape.angle)} ${px(shape.cx)} ${px(shape.cy)})`\n    return (\n      <g key={sign} data-servo={sign < 0 ? \"left\" : \"right\"} opacity={px(clamp(depth * 0.25 + 0.5, 0.12, 1))}>\n        <ellipse cx={px(shape.cx)} cy={px(shape.cy)} rx={px(shape.rx)} ry={px(shape.ry)} transform={turn} {...plate} />\n        <ellipse cx={px(core.cx)} cy={px(core.cy)} rx={px(core.rx)} ry={px(core.ry)} transform={turn} fill={palette.dark} stroke=\"none\" opacity={0.55} />\n      </g>\n    )\n  }\n\n  const column = (radiusX: number, radiusY: number, radiusZ: number, y: number) => {\n    const shape = ellipsoidOutline({ x: radiusX, y: radiusY, z: radiusZ }, restPose, camera, { x: 0, y, z: 0 })\n    return { cx: px(shape.cx), cy: px(shape.cy), rx: px(shape.rx), ry: px(shape.ry), angle: px(shape.angle) }\n  }\n  const neck = column(15, 13, 14, -48)\n  const shoulders = column(36, 10, 24, -64)\n  const shadow = column(44, 0.4, 30, -76)\n\n  // A behaviour part-way between two expressions has no name of its own, so the\n  // label falls back to what is driving it rather than saying \"blend\".\n  const name: string = expression ?? (typeof drive.expression === \"string\" ? drive.expression : behavior)\n\n  return (\n    <svg\n      ref={svgRef}\n      role=\"img\"\n      aria-label={`Animatronic face, ${name} at ${Math.round(solution.intensity * 100)} percent, neck yaw ${Math.round(pose.yaw)} degrees, ${viewNames[view] ?? viewNames.front}`}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        setPoked(clock)\n        onReact?.()\n      }}\n      viewBox=\"0 0 200 200\"\n      width={width}\n      height={width}\n      className={cn(\"max-w-full select-none\", interactive && \"cursor-pointer\", className)}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      <g data-view={view} transform=\"translate(100 86)\">\n        {showGround && <ellipse cx={shadow.cx} cy={shadow.cy} rx={shadow.rx} ry={Math.max(shadow.ry, 2)} fill={palette.dark} opacity={0.14} />}\n\n        {showNeck && (\n          <g data-neck>\n            <ellipse cx={shoulders.cx} cy={shoulders.cy} rx={shoulders.rx} ry={shoulders.ry} transform={`rotate(${shoulders.angle} ${shoulders.cx} ${shoulders.cy})`} {...cast} />\n            <ellipse cx={neck.cx} cy={neck.cy} rx={neck.rx} ry={neck.ry} transform={`rotate(${neck.angle} ${neck.cx} ${neck.cy})`} {...plate} />\n          </g>\n        )}\n\n        {showActuators &&\n          rods.map(({ actuator, tip, frame }) => (\n            <g key={actuator.id} data-actuator={actuator.id}>\n              <line\n                x1={px(frame.x)}\n                y1={px(frame.y)}\n                x2={px(tip.x)}\n                y2={px(tip.y)}\n                stroke={actuator.withinLimits ? palette.metal : palette.accent}\n                strokeWidth={1.4}\n                strokeLinecap=\"round\"\n                opacity={0.75}\n              />\n              <circle cx={px(frame.x)} cy={px(frame.y)} r={1.6} fill={palette.dark} />\n            </g>\n          ))}\n\n        <g data-head>\n          <ellipse\n            data-skull\n            cx={px(skull.cx)}\n            cy={px(skull.cy)}\n            rx={px(skull.rx)}\n            ry={px(skull.ry)}\n            transform={`rotate(${px(skull.angle)} ${px(skull.cx)} ${px(skull.cy)})`}\n            {...shell}\n          />\n          {/* A crown seam: a real meridian over the skull, so it curves with the\n              head from every angle rather than outlining the drawing. */}\n          <path\n            data-seam\n            d={Array.from({ length: 18 }, (_, index) => {\n              const angle = Math.PI * 0.34 + (index / 17) * Math.PI * 0.62\n              const point = toScreen({ x: 0, y: radii.y * Math.sin(angle), z: -radii.z * Math.cos(angle) })\n              return `${index ? \"L\" : \"M\"} ${px(point.x)} ${px(point.y)}`\n            }).join(\" \")}\n            fill=\"none\"\n            stroke={palette.dark}\n            strokeWidth={0.9}\n            opacity={0.3}\n          />\n          {/* The two edges that make a profile read as a face: a brow ridge and\n              a set chin, both standing off the skull the way the nose does. */}\n          <path\n            data-ridge\n            d={trace3(\n              [-1, -0.5, 0, 0.5, 1].map((t) => {\n                const seat = skin(t * 26, 21 - Math.abs(t) * 4, 0)\n                return { ...seat, z: seat.z - 2.6 * (1 - t * t * 0.7) }\n              }),\n              false,\n            )}\n            fill=\"none\"\n            stroke={palette.dark}\n            strokeWidth={1}\n            opacity={0.28}\n          />\n          {housing(-1)}\n          {housing(1)}\n          {[-1, 1].map((sign) => cheek(sign as -1 | 1))}\n          {band}\n          {[-1, 1].map((sign) => eye(sign as -1 | 1))}\n          {[-1, 1].map((sign) => brow(sign as -1 | 1))}\n          {nose}\n          {mouth}\n        </g>\n      </g>\n\n      {!solution.withinLimits && (\n        <circle data-fault cx={186} cy={14} r={4} fill={palette.accent} className=\"robocn-pulse\" />\n      )}\n      {label && (\n        <text x={100} y={194} textAnchor=\"middle\" fontFamily=\"ui-monospace, monospace\" fontSize={6} fill={palette.foreground}>\n          {label}\n        </text>\n      )}\n    </svg>\n  )\n}\n\nconst bound = (value: number, limit: number) =>\n  Number.isFinite(value) ? clamp(value, -limit, limit) : 0\n\nexport interface FaceDrive {\n  expression: FaceExpression | FaceChannels\n  intensity: number\n  blink: number\n  speech: number\n  gaze: Vec2\n  pose: HeadPose & { heave: number }\n}\n\n/** The expressions `emote` walks, in the order it walks them. */\nconst parade: FaceExpression[] = [\"neutral\", \"joy\", \"surprise\", \"doubt\", \"sorrow\", \"anger\", \"disgust\", \"fear\"]\n\n/**\n * An irregular blink. A clean sine blinks like a metronome, which reads as a\n * fault; warping the phase with a slower sine breaks the rhythm for one term.\n */\nexport function faceBlink(clock: number): number {\n  if (!Number.isFinite(clock)) return 0\n  const wave = clock * 1.1 + Math.sin(clock * 0.43) * 0.4\n  const cycle = wave - Math.floor(wave)\n  return cycle < 0.12 ? Math.sin((cycle / 0.12) * Math.PI) : 0\n}\n\n/**\n * What the head does on its own: a pure function of the clock, so the tests\n * read the motion instead of faking animation frames.\n *\n * `idle` breathes and glances about. `converse` runs a speech envelope through\n * the jaw and nods on phrase boundaries. `listen` holds the gaze, lifts the\n * brows and tilts. `emote` eases the whole expression set past the camera.\n */\nexport function faceBehaviorDrive(behavior: AnimatronicBehavior, clock: number): FaceDrive {\n  const t = Number.isFinite(clock) ? clock : 0\n  const turn = t * Math.PI * 2\n  const still: FaceDrive = {\n    expression: \"neutral\",\n    intensity: 1,\n    blink: 0,\n    speech: 0,\n    gaze: { x: 0, y: 0 },\n    pose: { yaw: 0, pitch: 0, roll: 0, heave: 0 },\n  }\n  if (behavior === \"static\") return still\n\n  switch (behavior) {\n    case \"converse\": {\n      // Syllables on top of a phrase envelope, so the jaw stops between\n      // sentences instead of chattering at one rate.\n      const phrase = Math.max(0, Math.sin(turn * 0.37))\n      const syllable = Math.abs(Math.sin(turn * 3.1)) * 0.7 + Math.abs(Math.sin(turn * 4.7)) * 0.3\n      return {\n        expression: blendFace(faceShape(\"neutral\"), faceShape(\"joy\"), 0.25 + Math.sin(turn * 0.5) * 0.15),\n        intensity: 1,\n        blink: faceBlink(t),\n        speech: phrase * syllable,\n        gaze: { x: Math.sin(turn * 0.61) * 0.35, y: Math.sin(turn * 0.29) * 0.2 },\n        pose: {\n          yaw: Math.sin(turn * 0.33) * 9,\n          pitch: Math.sin(turn * 0.74) * 5 - phrase * 3,\n          roll: Math.sin(turn * 0.21) * 3,\n          heave: Math.sin(turn * 0.9) * 0.8,\n        },\n      }\n    }\n    case \"listen\":\n      return {\n        expression: blendFace(faceShape(\"neutral\"), faceShape(\"doubt\"), 0.4),\n        intensity: 0.8,\n        blink: faceBlink(t * 0.8),\n        speech: 0,\n        gaze: { x: Math.sin(turn * 0.23) * 0.2, y: -0.1 },\n        pose: {\n          yaw: Math.sin(turn * 0.19) * 5,\n          pitch: -4 + Math.max(0, Math.sin(turn * 0.8)) * 7,\n          roll: 8 + Math.sin(turn * 0.27) * 3,\n          heave: Math.sin(turn * 0.8) * 0.7,\n        },\n      }\n    case \"emote\": {\n      const step = Math.floor(t) % parade.length\n      const next = (step + 1) % parade.length\n      const fraction = t - Math.floor(t)\n      // Hold the pose, then ease to the next one over the last third.\n      const eased = fraction < 0.66 ? 0 : (1 - Math.cos(((fraction - 0.66) / 0.34) * Math.PI)) / 2\n      return {\n        expression: blendFace(faceShape(parade[step]), faceShape(parade[next]), eased),\n        intensity: 1,\n        blink: faceBlink(t),\n        speech: 0,\n        gaze: { x: Math.sin(turn * 0.4) * 0.3, y: Math.sin(turn * 0.7) * 0.2 },\n        pose: {\n          yaw: Math.sin(turn * 0.31) * 8,\n          pitch: Math.sin(turn * 0.53) * 5,\n          roll: Math.sin(turn * 0.23) * 4,\n          heave: Math.sin(turn * 0.9) * 1,\n        },\n      }\n    }\n    default:\n      return {\n        // Never quite at rest: a dead-still neutral reads as switched off, so\n        // idle drifts a little way toward pleased and back.\n        expression: blendFace(faceShape(\"neutral\"), faceShape(\"joy\"), 0.16 + Math.sin(turn * 0.27) * 0.1),\n        intensity: 1,\n        blink: faceBlink(t),\n        speech: 0,\n        // Micro-saccades: the eyes flick and hold rather than sweeping.\n        gaze: {\n          x: Math.sin(turn * 0.31) * 0.35 + Math.sin(turn * 1.7) * 0.08,\n          y: Math.sin(turn * 0.47) * 0.22,\n        },\n        pose: {\n          yaw: Math.sin(turn * 0.23) * 6,\n          pitch: Math.sin(turn * 0.41) * 3,\n          roll: Math.sin(turn * 0.17) * 2.5,\n          heave: Math.sin(turn * 0.9) * 1.2,\n        },\n      }\n  }\n}\n\nexport { AnimatronicFace }\n",
      "type": "registry:ui",
      "target": "@ui/animatronic-face.tsx"
    }
  ],
  "categories": [
    "robotics",
    "droids"
  ],
  "type": "registry:ui"
}