{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rail-locomotive",
  "title": "Locomotive",
  "description": "An electric locomotive and its train, placed by the track rather than steered along it: each bogie takes the tangent under its own pivot, each body is the chord between two of them, and the centre and end throw fall out of that.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/rail-geometry.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/rail-locomotive.tsx",
      "content": "\"use client\"\n\n/**\n * rail-locomotive — an electric locomotive and the train behind it, placed by\n * the track rather than steered along it.\n *\n * Nothing on board steers. Bend the track and every pose in the drawing is an\n * answer to it: each bogie sits on the curve and takes the tangent under its\n * own pivot, each body is the straight chord between its two pivots, and the\n * sideways throw follows — the middle of a vehicle swings *inside* the curve\n * and its ends swing *outside*, which is the whole reason a long vehicle is a\n * clearance problem. Both numbers are solved, reported on the drawing, and go\n * to zero on straight track without a special case.\n *\n * On the roof, a pantograph on the same linkage `pantograph-collector` ships\n * standalone; underneath, the bogies are the ones `rail-bogie` draws on its\n * own. The consist is the same body repeated, each vehicle placed at its own\n * arc position, so the train genuinely bends along the curve instead of being\n * drawn bent.\n *\n * No dynamics: no traction, no braking, no cant, no transition spirals, and\n * nothing travels. The curve is a steady state.\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  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 {\n  bodyOffset,\n  bogieRide,\n  curveRadius,\n  pantographPose,\n  trackCurvature,\n} from \"@/lib/robocn/rail\"\nimport { cn } from \"@/lib/utils\"\n\nconst VIEW_WIDTH = 320\nconst VIEW_HEIGHT = 158\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** The locomotive in its own profile: rail head at y = 0, nose toward +x. */\nconst HALF_LENGTH = 66\nconst PIVOT_SPACING = 88\nconst FLOOR = 21\nconst ROOF = 64\nconst CAB_RAKE = 13\nconst HALF_BEAM = 17\nconst WHEEL_RADIUS = 10\nconst WHEEL_HALF_WIDTH = 3.4\nconst BOGIE_WHEELBASE = 30\nconst HALF_GAUGE = 11\n/** Between the buffer faces of two vehicles. */\nconst COUPLING_GAP = 13\nconst MAX_CURVE = 10\n/** Degrees of track turn per second while the curve eases back into a behaviour. */\nconst CURVE_RATE = 9\nconst MAX_CARS = 4\n\nconst PAN_GEOMETRY = {\n  lowerArm: 30,\n  upperArm: 26,\n  baseHeight: ROOF + 3,\n  rod: 22,\n  lever: 6,\n  rodAnchor: 0.45,\n  designHeight: ROOF + 3 + 46,\n}\nconst PAN_STOWED = PAN_GEOMETRY.baseHeight + 9\nconst PAN_RAISED = PAN_GEOMETRY.baseHeight + 46\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: the body tucks in at the solebar and at the roof. */\nconst beamAt = (y: number) =>\n  HALF_BEAM - Math.max(0, FLOOR + 6 - y) * 0.22 - Math.max(0, y - (ROOF - 7)) * 0.34\n\n/** One vehicle's profile outline: a body with a raked end at each cab. */\nconst bodyOutline = (rakeFront: number, rakeBack: number): Vec2[] => [\n  { x: -HALF_LENGTH, y: FLOOR },\n  { x: -HALF_LENGTH, y: ROOF - rakeBack },\n  { x: -HALF_LENGTH + rakeBack, y: ROOF },\n  { x: HALF_LENGTH - rakeFront, y: ROOF },\n  { x: HALF_LENGTH, y: ROOF - rakeFront },\n  { x: HALF_LENGTH, y: FLOOR },\n]\n\n/** An outline turned about one of its own points, in the drawing plane. */\nfunction tiltAbout(outline: Vec2[], about: Vec2, degrees: number): Vec2[] {\n  const angle = (clamp(degrees, -45, 45) * Math.PI) / 180\n  const cos = Math.cos(angle)\n  const sin = Math.sin(angle)\n  return outline.map((point) => {\n    const dx = point.x - about.x\n    const dy = point.y - about.y\n    return { x: about.x + dx * cos - dy * sin, y: about.y + dx * sin + dy * cos }\n  })\n}\n\nexport type RailLocomotiveBehavior = \"line\" | \"yard\" | \"depot\" | \"static\"\nexport type RailLocomotivePantograph = \"auto\" | \"raised\" | \"stowed\"\n\nexport interface RailLocomotiveProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /**\n   * Degrees the track turns through under one bogie-centre spacing, positive\n   * to starboard, clamped to ±10. Supplying it stops the loop.\n   */\n  curve?: number\n  onCurveChange?: (curve: number) => void\n  /** What the line does when `curve` is not supplied. */\n  behavior?: RailLocomotiveBehavior\n  /** Trailing vehicles behind the locomotive, 0–4. */\n  cars?: number\n  /** The roof collector: up, down, or whatever the behaviour is doing. */\n  pantograph?: RailLocomotivePantograph\n  view?: RobotView\n  /** The rails and sleepers the train is standing on. */\n  showTrack?: boolean\n  /** Call out the solved centre and end throw as dimension lines. Blueprint does by default. */\n  showThrow?: boolean\n  /** Light the headlight and the line indicators. Omit and it lights under power. */\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 RailLocomotive({\n  curve,\n  onCurveChange,\n  behavior = \"line\",\n  cars = 1,\n  pantograph = \"auto\",\n  view = NATIVE_VIEW,\n  showTrack = true,\n  showThrow,\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}: RailLocomotiveProps) {\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 = curve !== undefined\n\n  const hold = controlled\n    ? Number.isFinite(curve) ? clamp(curve as number, -MAX_CURVE, MAX_CURVE) : 0\n    : held\n  const goal = React.useCallback(\n    (clock: number) => locomotiveCurve(behavior, clock),\n    [behavior],\n  )\n  const motion = useRobotScalar(goal, {\n    rate: CURVE_RATE,\n    hold,\n    speed,\n    paused,\n    phase,\n    animate: animate && !controlled && behavior !== \"static\",\n  })\n  const turn = clamp(motion.value, -MAX_CURVE, MAX_CURVE)\n  const clock = Number.isFinite(motion.clock) ? motion.clock : 0\n  const lift =\n    pantograph === \"raised\" ? 1\n    : pantograph === \"stowed\" ? 0\n    : locomotivePan(behavior, clock)\n  const consist = Math.round(clamp(Number.isFinite(cars) ? cars : 1, 0, MAX_CARS))\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Math.round(clamp(next, -MAX_CURVE, MAX_CURVE) * 10) / 10\n      setHeld(bounded)\n      onCurveChange?.(bounded)\n    },\n    [onCurveChange],\n  )\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback(\n      (unit: Vec2) => apply((unit.x - 0.5) * 2 * MAX_CURVE),\n      [apply],\n    ),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  /* ---- the track, and what it does to every vehicle on it ---- */\n\n  const radius = curveRadius(turn, PIVOT_SPACING)\n  const sign = turn === 0 ? 0 : turn > 0 ? 1 : -1\n  const ride = bogieRide(sign === 0 ? Infinity : radius * sign, {\n    pivotSpacing: PIVOT_SPACING,\n    halfLength: HALF_LENGTH,\n  })\n  const pitch = HALF_LENGTH * 2 + COUPLING_GAP\n\n  /**\n   * The track, as arc length from the leading vehicle's centre. Curving to\n   * starboard puts the centre of the curve at +x, and the train runs toward\n   * −z, so a straight track is the z axis and the curve bends away from it by\n   * exactly `R(1 − cos φ)`.\n   */\n  const trackPoint = (s: number): { point: Vec3; forward: Vec2 } => {\n    if (sign === 0 || !Number.isFinite(radius)) {\n      return { point: { x: 0, y: 0, z: -s }, forward: { x: 0, y: -1 } }\n    }\n    const phi = s / radius\n    return {\n      point: {\n        x: sign * radius * (1 - Math.cos(phi)),\n        y: 0,\n        z: -radius * Math.sin(phi),\n      },\n      // d/ds of the above, which is a unit vector by construction.\n      forward: { x: sign * Math.sin(phi), y: -Math.cos(phi) },\n    }\n  }\n\n  /**\n   * A vehicle's own frame: the chord between its two pivots. The body is not\n   * placed on the curve — it is placed on the line between two points that\n   * are, which is what throws its middle in and its ends out.\n   */\n  const vehicleFrame = (centre: number) => {\n    const lead = trackPoint(centre + PIVOT_SPACING / 2)\n    const trail = trackPoint(centre - PIVOT_SPACING / 2)\n    const dx = lead.point.x - trail.point.x\n    const dz = lead.point.z - trail.point.z\n    const span = Math.hypot(dx, dz) || 1\n    const forward = { x: dx / span, z: dz / span }\n    // Starboard of a body heading `forward`.\n    const right = { x: -forward.z, z: forward.x }\n    const mid = {\n      x: (lead.point.x + trail.point.x) / 2,\n      z: (lead.point.z + trail.point.z) / 2,\n    }\n    const place = (point: Vec2, depth: number): Vec3 => ({\n      x: mid.x + forward.x * point.x + right.x * depth,\n      y: point.y,\n      z: mid.z + forward.z * point.x + right.z * depth,\n    })\n    return { place, lead, trail }\n  }\n\n  /** A bogie's own frame: it sits on the track, so it takes the tangent there. */\n  const bogieFrame = (at: { point: Vec3; forward: Vec2 }) => {\n    const forward = { x: at.forward.x, z: at.forward.y }\n    const right = { x: -forward.z, z: forward.x }\n    return (point: Vec2, depth: number): Vec3 => ({\n      x: at.point.x + forward.x * point.x + right.x * depth,\n      y: point.y,\n      z: at.point.z + forward.z * point.x + right.z * depth,\n    })\n  }\n\n  const camera = robotCamera(view)\n  const reach = pitch * consist + HALF_LENGTH + 18\n  // The envelope is the union of the whole track range this consist can be put\n  // on — straight, and hard over either way — so the framing is fixed per\n  // configuration and never breathes as the curve works. The nose sits at −z\n  // and the train trails off toward +z, so it is asymmetric the same way.\n  const envelope = React.useMemo(() => {\n    const nose = -(HALF_LENGTH + 18)\n    const side = HALF_BEAM + 6\n    let minX = -side\n    let maxX = side\n    const worst = curveRadius(MAX_CURVE, PIVOT_SPACING)\n    for (let index = 0; index <= 24; index += 1) {\n      const s = nose + ((reach - nose) * index) / 24\n      const swing = worst * (1 - Math.cos(Math.min(Math.PI, Math.abs(s) / worst)))\n      minX = Math.min(minX, -swing - side)\n      maxX = Math.max(maxX, swing + side)\n    }\n    return boxCorners(\n      { x: minX, y: -6, z: nose },\n      { x: maxX, y: PAN_RAISED + 8, z: reach },\n    )\n  }, [reach])\n  const frame = fitTransform(envelope, camera, VIEW_WIDTH, VIEW_HEIGHT)\n\n  /** The drafting helpers, for one placed frame. */\n  const draft = (place: (point: Vec2, depth: number) => Vec3) => ({\n    place,\n    solid: (outline: Vec2[], beam: (y: number) => number, offset = 0) =>\n      slabPath(\n        outline.flatMap((point) => [\n          place(point, offset + beam(point.y)),\n          place(point, offset - beam(point.y)),\n        ]),\n        camera,\n      ),\n    bar: (a: Vec2, b: Vec2, halfWidth: number, halfDepth: number, offset = 0) => {\n      const dx = b.x - a.x\n      const dy = b.y - a.y\n      const length = Math.hypot(dx, dy) || 1\n      const ux = dx / length\n      const uy = dy / length\n      const corners: Vec2[] = [\n        { x: a.x - uy * halfWidth - ux * halfWidth, y: a.y + ux * halfWidth - uy * halfWidth },\n        { x: a.x + uy * halfWidth - ux * halfWidth, y: a.y - ux * halfWidth - uy * halfWidth },\n        { x: b.x + uy * halfWidth + ux * halfWidth, y: b.y - ux * halfWidth + uy * halfWidth },\n        { x: b.x - uy * halfWidth + ux * halfWidth, y: b.y + ux * halfWidth + uy * halfWidth },\n      ]\n      return slabPath(\n        corners.flatMap((corner) => [\n          place(corner, offset + halfDepth),\n          place(corner, offset - halfDepth),\n        ]),\n        camera,\n      )\n    },\n    face: (points: Vec2[], depth: (y: number) => number, close = false) =>\n      `${points\n        .map((point, index) => {\n          const corner = place(point, depth(point.y))\n          const screen = camera.project(corner.x, corner.y, corner.z)\n          return `${index ? \"L\" : \"M\"} ${px(screen.x)} ${px(screen.y)}`\n        })\n        .join(\" \")}${close ? \" Z\" : \"\"}`,\n    across: (point: Vec2, from: number, to: number) => {\n      const a = place(point, from)\n      const b = place(point, to)\n      const start = camera.project(a.x, a.y, a.z)\n      const end = camera.project(b.x, b.y, b.z)\n      return `M ${px(start.x)} ${px(start.y)} L ${px(end.x)} ${px(end.y)}`\n    },\n    depth: (point: Vec2, offset = 0) => {\n      const corner = place(point, offset)\n      return camera.depth(corner.x, corner.y, corner.z)\n    },\n  })\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n  const powered = active ?? lift > 0.5\n\n  /** A wheel: a disc standing in the bogie's own plane. */\n  const wheel = (\n    place: (point: Vec2, depth: number) => Vec3,\n    at: number,\n    side: number,\n  ) =>\n    slabPath(\n      Array.from({ length: 16 }, (_, index) => {\n        const angle = (index / 16) * Math.PI * 2\n        return {\n          x: at + Math.cos(angle) * WHEEL_RADIUS,\n          y: WHEEL_RADIUS + Math.sin(angle) * WHEEL_RADIUS,\n        }\n      }).flatMap((point) => [\n        place(point, side * HALF_GAUGE + WHEEL_HALF_WIDTH),\n        place(point, side * HALF_GAUGE - WHEEL_HALF_WIDTH),\n      ]),\n      camera,\n    )\n\n  const vehicles = Array.from({ length: consist + 1 }, (_, index) => {\n    const centre = -index * pitch\n    const { place, lead, trail } = vehicleFrame(centre)\n    return {\n      index,\n      centre,\n      body: draft(place),\n      lead: draft(bogieFrame(lead)),\n      trail: draft(bogieFrame(trail)),\n      depth: (() => {\n        const corner = place({ x: 0, y: ROOF }, 0)\n        return camera.depth(corner.x, corner.y, corner.z)\n      })(),\n    }\n  }).sort((a, b) => a.depth - b.depth)\n\n  const pan = pantographPose(PAN_STOWED + (PAN_RAISED - PAN_STOWED) * lift, PAN_GEOMETRY)\n\n  const bogie = (\n    place: ReturnType<typeof draft>,\n    name: string,\n    yaw: number,\n    driven: boolean,\n  ) => (\n    <g key={name} data-bogie={name} data-yaw={px(yaw)}>\n      {[-1, 1].map((side) => (\n        <path\n          key={side}\n          data-wheel={`${name}-trail-${side < 0 ? \"port\" : \"starboard\"}`}\n          d={wheel(place.place, -BOGIE_WHEELBASE / 2, side)}\n          {...cast}\n        />\n      ))}\n      <path\n        d={place.solid(\n          [\n            { x: -BOGIE_WHEELBASE / 2 - 9, y: WHEEL_RADIUS - 3 },\n            { x: BOGIE_WHEELBASE / 2 + 9, y: WHEEL_RADIUS - 3 },\n            { x: BOGIE_WHEELBASE / 2 + 9, y: FLOOR - 2 },\n            { x: -BOGIE_WHEELBASE / 2 - 9, y: FLOOR - 2 },\n          ],\n          () => HALF_GAUGE - 1.5,\n        )}\n        {...machined}\n      />\n      {driven && (\n        <path\n          data-motor={name}\n          d={place.solid(\n            [\n              { x: -5, y: WHEEL_RADIUS - 3 },\n              { x: 6, y: WHEEL_RADIUS - 3 },\n              { x: 6, y: WHEEL_RADIUS + 6 },\n              { x: -5, y: WHEEL_RADIUS + 6 },\n            ],\n            () => HALF_GAUGE - 4,\n          )}\n          {...cast}\n        />\n      )}\n      {[-1, 1].map((side) => (\n        <path\n          key={side}\n          data-wheel={`${name}-lead-${side < 0 ? \"port\" : \"starboard\"}`}\n          d={wheel(place.place, BOGIE_WHEELBASE / 2, side)}\n          {...cast}\n        />\n      ))}\n    </g>\n  )\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Rail locomotive and ${consist} ${consist === 1 ? \"car\" : \"cars\"}, track turning ${Math.round(turn)} degrees under a bogie spacing, pantograph ${lift > 0.5 ? \"raised\" : \"stowed\"}, ${viewNames[view] ?? viewNames.profile}`\n      }\n      aria-valuemin={interactive ? -MAX_CURVE : undefined}\n      aria-valuemax={interactive ? MAX_CURVE : undefined}\n      aria-valuenow={interactive ? px(turn) : undefined}\n      aria-valuetext={interactive ? `track turning ${Math.round(turn)} 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 ? 3 : 1, 7)\n        if (delta !== 0) apply(turn + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(MAX_CURVE)\n        else if (event.key === \"Escape\") setHeld(null)\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 - 20} H ${VIEW_WIDTH - 10}`} strokeDasharray=\"3 4\" />\n        </g>\n      )}\n\n      <g\n        data-view={view}\n        data-curve={px(turn)}\n        data-centre-throw={px(ride.centreThrow)}\n        data-end-throw={px(ride.endThrow)}\n        data-pantograph-height={px(lift)}\n        transform={frame || undefined}\n      >\n        {showTrack && (\n          <g data-track fill=\"none\" stroke={palette.dark} opacity={0.42}>\n            {[-1, 1].map((side) => (\n              <path\n                key={side}\n                data-rail={side < 0 ? \"port\" : \"starboard\"}\n                d={(() => {\n                  const samples = 44\n                  const from = HALF_LENGTH + 18\n                  const to = -(pitch * consist + HALF_LENGTH + 18)\n                  return Array.from({ length: samples }, (_, index) => {\n                    const s = from + ((to - from) * index) / (samples - 1)\n                    const at = trackPoint(s)\n                    const right = { x: -at.forward.y, z: at.forward.x }\n                    const point = camera.project(\n                      at.point.x + right.x * side * HALF_GAUGE,\n                      0,\n                      at.point.z + right.z * side * HALF_GAUGE,\n                    )\n                    return `${index ? \"L\" : \"M\"} ${px(point.x)} ${px(point.y)}`\n                  }).join(\" \")\n                })()}\n                strokeWidth={1.5}\n              />\n            ))}\n          </g>\n        )}\n\n        {vehicles.map((vehicle) => {\n          const leading = vehicle.index === 0\n          const outline = leading\n            ? bodyOutline(CAB_RAKE, 5)\n            : bodyOutline(5, 5)\n          const name = leading ? \"locomotive\" : `car-${vehicle.index - 1}`\n          const bodyDepth = vehicle.body.depth({ x: 0, y: (FLOOR + ROOF) / 2 })\n          const near = (place: ReturnType<typeof draft>) =>\n            place.depth({ x: 0, y: WHEEL_RADIUS }) > bodyDepth\n          return (\n            <g key={name} data-vehicle={name} data-throw={px(bodyOffset(HALF_LENGTH, ride.radius, PIVOT_SPACING))}>\n              {!near(vehicle.lead) &&\n                bogie(vehicle.lead, `${name}-lead`, ride.bogies[0].yaw, leading)}\n              {!near(vehicle.trail) &&\n                bogie(vehicle.trail, `${name}-trail`, ride.bogies[1].yaw, leading)}\n\n              {/* Couplers, drawn behind the body: the gap the vehicles keep. */}\n              {(leading || vehicle.index < consist) && (\n                <path\n                  data-coupler={name}\n                  d={vehicle.body.solid(\n                    [\n                      { x: -HALF_LENGTH - COUPLING_GAP / 2, y: FLOOR - 6 },\n                      { x: -HALF_LENGTH, y: FLOOR - 6 },\n                      { x: -HALF_LENGTH, y: FLOOR - 1 },\n                      { x: -HALF_LENGTH - COUPLING_GAP / 2, y: FLOOR - 1 },\n                    ],\n                    () => 2.6,\n                  )}\n                  {...machined}\n                />\n              )}\n\n              {/* Solebar: a member down each side of the underframe, so it is a\n                  band in elevation and two lines from above — not a floor. */}\n              {[-1, 1].map((side) => (\n                <path\n                  key={side}\n                  data-solebar={side < 0 ? \"port\" : \"starboard\"}\n                  d={vehicle.body.solid(\n                    [\n                      { x: -HALF_LENGTH, y: FLOOR - 3.5 },\n                      { x: HALF_LENGTH, y: FLOOR - 3.5 },\n                      { x: HALF_LENGTH, y: FLOOR + 1 },\n                      { x: -HALF_LENGTH, y: FLOOR + 1 },\n                    ],\n                    () => 1.2,\n                    side * (HALF_BEAM - 2),\n                  )}\n                  {...cast}\n                />\n              ))}\n              <path data-body={name} d={vehicle.body.solid(outline, beamAt)} {...shell} />\n\n\n              {/* The window band, and on a carriage the doors that break it. */}\n              <path\n                data-glazing={name}\n                d={vehicle.body.face(\n                  [\n                    { x: -HALF_LENGTH + 10, y: ROOF - 22 },\n                    { x: HALF_LENGTH - (leading ? CAB_RAKE + 12 : 10), y: ROOF - 22 },\n                    { x: HALF_LENGTH - (leading ? CAB_RAKE + 12 : 10), y: ROOF - 9 },\n                    { x: -HALF_LENGTH + 10, y: ROOF - 9 },\n                  ],\n                  (y) => beamAt(y) + 0.4,\n                  true,\n                )}\n                {...cast}\n              />\n              {!leading &&\n                [-30, 30].map((at) => (\n                  <path\n                    key={at}\n                    data-door={`${name}-${at}`}\n                    d={vehicle.body.face(\n                      [\n                        { x: at - 6, y: FLOOR + 2 },\n                        { x: at + 6, y: FLOOR + 2 },\n                        { x: at + 6, y: ROOF - 6 },\n                        { x: at - 6, y: ROOF - 6 },\n                      ],\n                      (y) => beamAt(y) + 0.5,\n                      true,\n                    )}\n                    fill=\"none\"\n                    stroke={palette.dark}\n                    strokeWidth={1.1}\n                    opacity={0.5}\n                  />\n                ))}\n\n              {leading && (\n                <>\n                  {/* The cab: a raked screen, and the light under it. */}\n                  <path\n                    data-cab\n                    d={vehicle.body.face(\n                      [\n                        { x: HALF_LENGTH - CAB_RAKE, y: ROOF - 2 },\n                        { x: HALF_LENGTH - 2, y: ROOF - CAB_RAKE },\n                        { x: HALF_LENGTH - 2, y: ROOF - 22 },\n                        { x: HALF_LENGTH - CAB_RAKE - 3, y: ROOF - 22 },\n                      ],\n                      (y) => beamAt(y) + 0.5,\n                      true,\n                    )}\n                    {...cast}\n                  />\n                  <path\n                    data-lamp=\"head\"\n                    d={vehicle.body.across(\n                      { x: HALF_LENGTH - 1, y: ROOF - 28 },\n                      -(beamAt(ROOF - 28) - 4),\n                      beamAt(ROOF - 28) - 4,\n                    )}\n                    fill=\"none\"\n                    stroke={powered ? palette.accent : palette.metal}\n                    strokeWidth={3}\n                    strokeLinecap=\"round\"\n                    opacity={powered ? 1 : 0.5}\n                  />\n                  {/* Louvres: the machine room breathing, three marks not thirty. */}\n                  {[-34, -22, -10].map((at) => (\n                    <path\n                      key={at}\n                      d={vehicle.body.face(\n                        [\n                          { x: at, y: FLOOR + 6 },\n                          { x: at, y: ROOF - 26 },\n                        ],\n                        (y) => beamAt(y) + 0.4,\n                      )}\n                      fill=\"none\"\n                      stroke={palette.dark}\n                      strokeWidth={1.4}\n                      opacity={0.45}\n                    />\n                  ))}\n                  {/* Roof kit: insulators, then the collector on top of them. */}\n                  {[-46, 40].map((at) => (\n                    <path\n                      key={at}\n                      d={vehicle.body.solid(\n                        [\n                          { x: at - 5, y: ROOF },\n                          { x: at + 5, y: ROOF },\n                          { x: at + 5, y: ROOF + 4 },\n                          { x: at - 5, y: ROOF + 4 },\n                        ],\n                        () => beamAt(ROOF) - 3,\n                      )}\n                      {...machined}\n                    />\n                  ))}\n                  <g data-pantograph data-height={px(lift)}>\n                    <path\n                      d={vehicle.body.solid(\n                        [\n                          { x: -16, y: ROOF },\n                          { x: 16, y: ROOF },\n                          { x: 16, y: PAN_GEOMETRY.baseHeight },\n                          { x: -16, y: PAN_GEOMETRY.baseHeight },\n                        ],\n                        () => beamAt(ROOF) - 4,\n                      )}\n                      {...cast}\n                    />\n                    <path\n                      data-lower-arm\n                      d={vehicle.body.bar(pan.base, pan.knee, 1.9, 2.6)}\n                      {...machined}\n                    />\n                    <path\n                      data-upper-arm\n                      d={vehicle.body.bar(pan.knee, pan.head, 1.4, 2)}\n                      {...machined}\n                    />\n                    <path\n                      data-knee\n                      d={vehicle.body.bar(pan.knee, pan.knee, 2.6, 3.2)}\n                      {...cast}\n                    />\n                    <path\n                      data-pan-rod\n                      d={vehicle.body.face([pan.rodAnchor, pan.leverEnd], () => 0)}\n                      fill=\"none\"\n                      stroke={palette.dark}\n                      strokeWidth={1}\n                      opacity={0.8}\n                    />\n                    <g data-pan data-attitude={px(pan.attitude)}>\n                      {/* The head is a strip across the track, tilted by the\n                          attitude the levelling loop produced — level at the\n                          height it was set for, and visibly not at the ends. */}\n                      <path\n                        data-strip\n                        d={vehicle.body.solid(\n                          tiltAbout(\n                            [\n                              { x: pan.head.x - 7, y: pan.head.y },\n                              { x: pan.head.x + 7, y: pan.head.y },\n                              { x: pan.head.x + 7, y: pan.head.y + 2.6 },\n                              { x: pan.head.x - 7, y: pan.head.y + 2.6 },\n                            ],\n                            pan.head,\n                            pan.attitude,\n                          ),\n                          () => beamAt(ROOF) - 3,\n                        )}\n                        fill={powered ? palette.accent : palette.metal}\n                        stroke={palette.dark}\n                        strokeWidth={0.5}\n                      />\n                      {/* The horns: the ends of the strip turned down, so a\n                          wire running off the side is led back on. */}\n                      {[-1, 1].map((side) => (\n                        <path\n                          key={side}\n                          data-horn={side < 0 ? \"port\" : \"starboard\"}\n                          d={vehicle.body.face(\n                            [\n                              { x: pan.head.x - 6, y: pan.head.y + 2.6 },\n                              { x: pan.head.x + 6, y: pan.head.y + 2.6 },\n                            ],\n                            () => side * (beamAt(ROOF) - 3),\n                          )}\n                          fill=\"none\"\n                          stroke={palette.metal}\n                          strokeWidth={1.6}\n                          strokeLinecap=\"round\"\n                        />\n                      ))}\n                    </g>\n                  </g>\n                </>\n              )}\n\n              {near(vehicle.lead) &&\n                bogie(vehicle.lead, `${name}-lead`, ride.bogies[0].yaw, leading)}\n              {near(vehicle.trail) &&\n                bogie(vehicle.trail, `${name}-trail`, ride.bogies[1].yaw, leading)}\n            </g>\n          )\n        })}\n\n        {(showThrow ?? variant === \"blueprint\") && ride.sign !== 0 && (\n          <g data-throw-marks fill=\"none\" stroke={palette.grid} strokeWidth={0.7} opacity={0.9}>\n            {/* The two clearance numbers, drawn where they are measured: the\n                middle of the leading body, and its leading corner. */}\n            {(() => {\n              const { place } = vehicleFrame(0)\n              const mark = (x: number) => {\n                const on = place({ x, y: FLOOR - 7 }, 0)\n                const track = trackPoint(x).point\n                const a = camera.project(on.x, on.y, on.z)\n                const b = camera.project(track.x, 0, track.z)\n                return `M ${px(a.x)} ${px(a.y)} L ${px(b.x)} ${px(b.y)}`\n              }\n              return (\n                <>\n                  <path data-throw-centre d={mark(0)} strokeDasharray=\"2 2\" />\n                  <path data-throw-end d={mark(HALF_LENGTH)} strokeDasharray=\"2 2\" />\n                </>\n              )\n            })()}\n          </g>\n        )}\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/** How hard the track is turning at `clock`, in degrees under a bogie spacing. */\nexport function locomotiveCurve(\n  behavior: RailLocomotiveBehavior,\n  clock: number,\n): number {\n  if (behavior === \"static\" || behavior === \"depot\" || !Number.isFinite(clock)) return 0\n  if (behavior === \"yard\") {\n    // A crossover: hard over one way, held, then hard over the other.\n    const cycle = ((clock % 1) + 1) % 1\n    if (cycle < 0.3) return MAX_CURVE\n    if (cycle < 0.5) return MAX_CURVE * (1 - (cycle - 0.3) / 0.1)\n    if (cycle < 0.8) return -MAX_CURVE\n    return -MAX_CURVE * (1 - (cycle - 0.8) / 0.1)\n  }\n  return trackCurvature(clock, MAX_CURVE)\n}\n\n/**\n * Where the roof collector is at `clock`, 0 stowed to 1 at the wire. Under\n * power it is simply up; the depot is where a pantograph is actually worked,\n * so that is the behaviour that runs it.\n */\nexport function locomotivePan(\n  behavior: RailLocomotiveBehavior,\n  clock: number,\n): number {\n  if (behavior === \"static\" || !Number.isFinite(clock)) {\n    return behavior === \"depot\" ? 0 : 1\n  }\n  if (behavior !== \"depot\") return 1\n  // Preparation: stowed, run up to the wire, hold there, and back down.\n  const cycle = ((clock % 1) + 1) % 1\n  if (cycle < 0.18) return 0\n  if (cycle < 0.38) return (cycle - 0.18) / 0.2\n  if (cycle < 0.72) return 1\n  if (cycle < 0.92) return 1 - (cycle - 0.72) / 0.2\n  return 0\n}\n\nexport { RailLocomotive }\n",
      "type": "registry:ui",
      "target": "@ui/rail-locomotive.tsx"
    }
  ],
  "categories": [
    "robotics",
    "rail"
  ],
  "type": "registry:ui"
}