{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hydrofoil-craft",
  "title": "Hydrofoil craft",
  "description": "A foilborne ferry that climbs out of its own ground plane: lift goes as the square of speed, so the surface-piercing V sheds immersed area and the hull rises clear of the water.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/vehicle-geometry.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/hydrofoil-craft.tsx",
      "content": "\"use client\"\n\n/**\n * hydrofoil-craft — the one machine in the set that climbs out of its own\n * ground plane.\n *\n * Everything else stands on the floor or floats in it. This one leaves it.\n * Lift goes as the *square* of speed, so a surface-piercing foil carrying a\n * steady weight has to shed immersed area as `1/v²` — and the only way it can\n * is by rising until less of the V is in the water. That is the whole machine:\n * `knots` sets the rise, the rise lifts the hull, and the wetted length of each\n * foil limb is what is left below the waterline.\n *\n * Two things follow for free. Below the takeoff speed the foil cannot carry the\n * boat at all, so it stays hullborne and rides the swell; foilborne, the struts\n * are all that touch the water and the ride goes quiet — which is what a\n * hydrofoil is for.\n *\n * The lift relation is real. Nothing else here is: no drag, no wave-making, no\n * cavitation, no righting moment, and the swell is a stated shape.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp, lerp, toRadians, type Vec2, type Vec3 } from \"@/lib/robocn/kinematics\"\nimport {\n  boxCorners,\n  fitTransform,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  slabPath,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { foilRise, rollPoint } from \"@/lib/robocn/vehicle\"\nimport { cn } from \"@/lib/utils\"\n\nconst VIEW_WIDTH = 290\nconst VIEW_HEIGHT = 170\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** The craft in its own profile: keel at y = 0, bow toward +x. */\nconst STERN = -4\nconst BOW = 236\nconst KEEL = 0\nconst DECK = 34\nconst HALF_BEAM = 26\n/** How deep the keel sits when the foils are carrying nothing. */\nconst DRAFT = 11\n/** How far the keel stands clear once the boat is fully foilborne. */\nconst CLEARANCE = 20\n/** Strut stations, and how far the foils hang below the keel. */\nconst FORE_STRUT = 178\nconst AFT_STRUT = 44\nconst STRUT_DROP = 22\n/** Half-span of the V, and how far its tips stand above its root. */\nconst FOIL_SPAN = 30\nconst FOIL_DIHEDRAL = 18\nconst MID = (STERN + BOW) / 2\nconst MAX_KNOTS = 60\n/** Knots per second the throttle moves at while easing back into a behaviour. */\nconst THROTTLE_RATE = 11\n\nconst ENVELOPE = boxCorners(\n  { x: -40, y: -46, z: -244 },\n  { x: 40, y: 82, z: 12 },\n)\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/** Half-beam at a height: fine at the keel, full at the deck. */\nconst beamAt = (y: number) => HALF_BEAM * clamp(0.42 + (y - KEEL) / 44, 0.3, 1)\n\n/** The hull's sheer, from the transom to the stem. */\nconst HULL: Vec2[] = [\n  { x: STERN, y: KEEL + 6 },\n  { x: STERN, y: DECK },\n  { x: BOW - 40, y: DECK + 3 },\n  { x: BOW, y: DECK - 2 },\n  { x: BOW - 16, y: KEEL + 9 },\n  { x: STERN + 26, y: KEEL },\n]\n\n/** The cabin, set in from the sheer and raked at both ends. */\nconst CABIN: Vec2[] = [\n  { x: STERN + 24, y: DECK },\n  { x: STERN + 34, y: DECK + 26 },\n  { x: BOW - 56, y: DECK + 28 },\n  { x: BOW - 38, y: DECK },\n]\n\nexport type HydrofoilBehavior = \"takeoff\" | \"foilborne\" | \"moor\" | \"static\"\n\nexport interface HydrofoilCraftProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Speed through the water. Supplying it stops the loop. */\n  knots?: number\n  onKnotsChange?: (knots: number) => void\n  /** The speed the foils can first carry the boat at. */\n  takeoffSpeed?: number\n  behavior?: HydrofoilBehavior\n  view?: RobotView\n  /** The sea, the waterline, and the hull's shadow in it. */\n  showSea?: boolean\n  /** Spray off the struts. Omit and it appears whenever they are cutting water. */\n  active?: boolean\n  interactive?: boolean\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  phase?: number\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction HydrofoilCraft({\n  knots,\n  onKnotsChange,\n  takeoffSpeed = 18,\n  behavior = \"takeoff\",\n  view = NATIVE_VIEW,\n  showSea = true,\n  active,\n  interactive = false,\n  speed = 0.22,\n  animate = true,\n  paused = false,\n  phase = 0,\n  label,\n  size = \"md\",\n  variant = \"solid\",\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  \"aria-label\": ariaLabel,\n  ...props\n}: HydrofoilCraftProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n  const controlled = knots !== undefined\n\n  const hold = controlled\n    ? Number.isFinite(knots) ? clamp(knots as number, 0, MAX_KNOTS) : 0\n    : held\n  const goal = React.useCallback(\n    (clock: number) => craftKnots(behavior, clock),\n    [behavior],\n  )\n  const motion = useRobotScalar(goal, {\n    rate: THROTTLE_RATE,\n    hold,\n    speed,\n    paused,\n    phase,\n    animate: animate && !controlled && behavior !== \"static\",\n  })\n  const through = clamp(motion.value, 0, MAX_KNOTS)\n  const takeoff = Number.isFinite(takeoffSpeed) ? clamp(takeoffSpeed, 1, MAX_KNOTS) : 18\n  const rise = foilRise(through, takeoff)\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Math.round(clamp(next, 0, MAX_KNOTS) * 10) / 10\n      setHeld(bounded)\n      onKnotsChange?.(bounded)\n    },\n    [onKnotsChange],\n  )\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit: Vec2) => apply(unit.x * MAX_KNOTS), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  // Hullborne, the boat rides the swell. Foilborne, the struts are all that\n  // touch it and the ride goes quiet — which is the point of the machine.\n  const swell = (1 - rise) * (through > 0.5 ? 1 : 0.45)\n  const clock = Number.isFinite(motion.clock) ? motion.clock : 0\n  const heave = Math.sin(clock * Math.PI * 2) * 2.6 * swell\n  const trim = Math.sin(clock * Math.PI * 2 + 0.9) * 1.7 * swell\n  // The bow lifts through the transition, then settles as she comes up.\n  const attitude = trim + Math.sin(Math.PI * clamp(rise * 1.6, 0, 1)) * 3.2\n  const float = lerp(-DRAFT, CLEARANCE, rise) + heave\n\n  const camera = robotCamera(view)\n  const frame = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT)\n  const pitch = toRadians(attitude)\n  const cosPitch = Math.cos(pitch)\n  const sinPitch = Math.sin(pitch)\n\n  /** A drawing point on the craft: trimmed about amidships, then floated. */\n  const afloat = (point: Vec2): Vec2 => {\n    const dx = point.x - MID\n    const dy = point.y - DECK / 2\n    return {\n      x: MID + dx * cosPitch - dy * sinPitch,\n      y: DECK / 2 + dx * sinPitch + dy * cosPitch + float,\n    }\n  }\n  const world = (point: Vec2, depth: number): Vec3 => rollPoint(afloat(point), depth, 0)\n\n  const solid = (outline: Vec2[], beam: (y: number) => number, offset = 0) =>\n    slabPath(\n      outline.flatMap((point) => [\n        world(point, offset + beam(point.y)),\n        world(point, offset - beam(point.y)),\n      ]),\n      camera,\n    )\n  const to = (point: Vec2, depth: number) => {\n    const corner = world(point, depth)\n    return camera.project(corner.x, corner.y, corner.z)\n  }\n  const face = (points: Vec2[], depth: (y: number) => number, close = false) =>\n    `${points\n      .map((point, index) => {\n        const screen = to(point, depth(point.y))\n        return `${index ? \"L\" : \"M\"} ${px(screen.x)} ${px(screen.y)}`\n      })\n      .join(\" \")}${close ? \" Z\" : \"\"}`\n  /** A member from one side of the machine to the other at one station. */\n  const across = (point: Vec2, from: number, toDepth: number) => {\n    const a = to(point, from)\n    const b = to(point, toDepth)\n    return `M ${px(a.x)} ${px(a.y)} L ${px(b.x)} ${px(b.y)}`\n  }\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const making = active ?? through > 2\n  const readout = Math.round(through)\n\n  /**\n   * One foil assembly: a strut down from the keel, and a V whose limbs pierce\n   * the surface. What is drawn wetted is genuinely the part below y = 0 after\n   * the boat has risen — the area the lift equation just took away.\n   */\n  const assembly = (name: string, station: number) => {\n    const root: Vec2 = { x: station, y: KEEL - STRUT_DROP }\n    const rootWorld = afloat(root)\n    const tipHeight = rootWorld.y + FOIL_DIHEDRAL\n    // Where the limb crosses the surface, as a fraction of its own span.\n    const cut =\n      tipHeight === rootWorld.y\n        ? rootWorld.y < 0 ? 1 : 0\n        : clamp((0 - rootWorld.y) / (tipHeight - rootWorld.y), 0, 1)\n    const strutTop = afloat({ x: station, y: KEEL + 4 })\n    // Spray comes off where the strut crosses the surface, not off the foil.\n    const surface = camera.project(0, 0, -lerp(strutTop.x, rootWorld.x, 0.5))\n    return (\n      <g key={name} data-foil={name} data-wetted={px(cut)}>\n        <path\n          data-strut={name}\n          d={slabPath(\n            [\n              { x: 4, y: strutTop.y, z: -strutTop.x },\n              { x: -4, y: strutTop.y, z: -strutTop.x },\n              { x: 4, y: rootWorld.y, z: -rootWorld.x },\n              { x: -4, y: rootWorld.y, z: -rootWorld.x },\n              { x: 4, y: strutTop.y, z: -strutTop.x - 9 },\n              { x: -4, y: strutTop.y, z: -strutTop.x - 9 },\n              { x: 4, y: rootWorld.y, z: -rootWorld.x - 3 },\n              { x: -4, y: rootWorld.y, z: -rootWorld.x - 3 },\n            ],\n            camera,\n          )}\n          {...machined}\n        />\n        {[-1, 1].map((side) => {\n          const tip = {\n            x: side * FOIL_SPAN,\n            y: tipHeight,\n            z: -rootWorld.x,\n          }\n          const wet = {\n            x: side * FOIL_SPAN * cut,\n            y: lerp(rootWorld.y, tipHeight, cut),\n            z: -rootWorld.x,\n          }\n          const a = camera.project(0, rootWorld.y, -rootWorld.x)\n          const b = camera.project(tip.x, tip.y, tip.z)\n          const c = camera.project(wet.x, wet.y, wet.z)\n          return (\n            <g key={side}>\n              <path\n                d={`M ${px(a.x)} ${px(a.y)} L ${px(b.x)} ${px(b.y)}`}\n                fill=\"none\"\n                stroke={variant === \"solid\" ? palette.dark : palette.metal}\n                strokeWidth={4.5}\n                strokeLinecap=\"round\"\n              />\n              {cut > 0.01 && (\n                <path\n                  data-immersed={`${name}-${side < 0 ? \"port\" : \"starboard\"}`}\n                  d={`M ${px(a.x)} ${px(a.y)} L ${px(c.x)} ${px(c.y)}`}\n                  fill=\"none\"\n                  stroke={palette.accent}\n                  strokeWidth={2.6}\n                  strokeLinecap=\"round\"\n                  opacity={0.9}\n                />\n              )}\n            </g>\n          )\n        })}\n        {making && strutTop.y > 0 && rootWorld.y < 0 && (\n          <path\n            data-spray={name}\n            d={`M ${px(surface.x - 7)} ${px(surface.y)} l 5 -7 l 4 7 l 5 -9`}\n            fill=\"none\"\n            stroke={palette.glow}\n            strokeWidth={1.4}\n            opacity={0.7}\n          />\n        )}\n      </g>\n    )\n  }\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Hydrofoil craft, ${readout} knots, ${rise > 0 ? `${Math.round(rise * 100)} percent foilborne` : \"hullborne\"}, ${viewNames[view] ?? viewNames.profile}`\n      }\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? MAX_KNOTS : undefined}\n      aria-valuenow={interactive ? px(through) : undefined}\n      aria-valuetext={interactive ? `${readout} knots` : 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 ? 5 : 2, 10)\n        if (delta !== 0) apply(through + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(MAX_KNOTS)\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 d={`M 10 ${VIEW_HEIGHT - 40} H ${VIEW_WIDTH - 10}`} strokeDasharray=\"3 4\" />\n        </g>\n      )}\n\n      <g data-view={view} data-rise={px(rise)} data-knots={px(through)} transform={frame || undefined}>\n        {showSea && (\n          <g data-waterline>\n            {/* The sea is a plane, so it is a wash from above and a line from\n                the side — which is exactly what a waterline is. */}\n            <path\n              d={slabPath(\n                [\n                  { x: 36, y: 0, z: 14 },\n                  { x: -36, y: 0, z: 14 },\n                  { x: 36, y: 0, z: -BOW - 18 },\n                  { x: -36, y: 0, z: -BOW - 18 },\n                ],\n                camera,\n              )}\n              fill={palette.dark}\n              opacity={0.16}\n            />\n            <path\n              d={(() => {\n                const a = camera.project(0, 0, 16)\n                const b = camera.project(0, 0, -BOW - 20)\n                return `M ${px(a.x)} ${px(a.y)} L ${px(b.x)} ${px(b.y)}`\n              })()}\n              fill=\"none\"\n              stroke={palette.dark}\n              strokeWidth={1.6}\n              opacity={0.5}\n            />\n          </g>\n        )}\n\n        {assembly(\"aft\", AFT_STRUT)}\n        {assembly(\"fore\", FORE_STRUT)}\n\n        <g data-hull>\n          <path d={solid(HULL, beamAt)} {...shell} />\n          {/* The boot top, painted on the hull, so it goes under when she sits. */}\n          <path\n            d={face(\n              [\n                { x: STERN + 4, y: KEEL + 9 },\n                { x: BOW - 20, y: KEEL + 13 },\n              ],\n              (y) => beamAt(y) + 0.4,\n            )}\n            fill=\"none\"\n            stroke={palette.dark}\n            strokeWidth={3}\n            opacity={0.55}\n          />\n          <path d={solid(CABIN, (y) => beamAt(DECK) - 4 - Math.max(0, y - DECK) * 0.12)} {...machined} />\n          <path\n            data-glazing\n            d={face(\n              [\n                { x: STERN + 40, y: DECK + 8 },\n                { x: BOW - 62, y: DECK + 10 },\n                { x: BOW - 62, y: DECK + 21 },\n                { x: STERN + 40, y: DECK + 19 },\n              ],\n              () => beamAt(DECK) - 3.4,\n              true,\n            )}\n            fill={palette.dark}\n            opacity={0.6}\n          />\n          {/* Bridge, right forward, and the mast over it. */}\n          <path\n            data-bridge\n            d={solid(\n              [\n                { x: BOW - 54, y: DECK + 28 },\n                { x: BOW - 56, y: DECK + 44 },\n                { x: BOW - 86, y: DECK + 44 },\n                { x: BOW - 88, y: DECK + 28 },\n              ],\n              () => beamAt(DECK) - 8,\n            )}\n            {...shell}\n          />\n          <path\n            d={across({ x: BOW - 62, y: DECK + 40 }, -(beamAt(DECK) - 8.4), beamAt(DECK) - 8.4)}\n            fill=\"none\"\n            stroke={palette.dark}\n            strokeWidth={3.4}\n            opacity={0.6}\n          />\n          <path\n            data-mast\n            d={face(\n              [\n                { x: BOW - 72, y: DECK + 44 },\n                { x: BOW - 74, y: DECK + 70 },\n              ],\n              () => 0,\n            )}\n            fill=\"none\"\n            stroke={palette.metal}\n            strokeWidth={2.2}\n          />\n          <path\n            data-lamp\n            d={across({ x: BOW - 74, y: DECK + 71 }, -2, 2)}\n            fill=\"none\"\n            stroke={making ? palette.accent : palette.metal}\n            strokeWidth={3.4}\n            strokeLinecap=\"round\"\n          />\n        </g>\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 5}\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/** The speed the craft is working up to at `clock`, in knots. */\nexport function craftKnots(behavior: HydrofoilBehavior, clock: number): number {\n  if (behavior === \"static\" || !Number.isFinite(clock)) return 0\n  switch (behavior) {\n    case \"foilborne\":\n      return 42 + Math.sin(clock * Math.PI * 2) * 4\n    case \"moor\":\n      return Math.max(0, Math.sin(clock * Math.PI * 2) * 5)\n    default:\n      // Away from the berth, up onto the foils, and back down again.\n      return 24 + Math.sin(clock * Math.PI * 2) * 24\n  }\n}\n\nexport { HydrofoilCraft }\n",
      "type": "registry:ui",
      "target": "@ui/hydrofoil-craft.tsx"
    }
  ],
  "categories": [
    "robotics",
    "vehicles"
  ],
  "type": "registry:ui"
}