{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "construct-ring",
  "title": "Construct ring",
  "description": "A signet emitter ring that lights, comes apart, and forges a drawn stroke into a construct of solid light.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/use-robot-motion.json",
    "https://robocn.dev/r/construct-geometry.json",
    "https://robocn.dev/r/assembly-geometry.json"
  ],
  "files": [
    {
      "path": "src/components/ui/construct-ring.tsx",
      "content": "\"use client\"\n\n/**\n * construct-ring — a signet emitter ring that lights, comes apart, and forges\n * what the reader draws into a construct of solid light.\n *\n * The form is the heavy signet archetype: a broad knurled band with flared\n * shoulders, a collar, a bezel plate flanked by two crystal inlays, and a domed\n * lens whose face carries an abstract iris — a bore, four radial inlays and a\n * spiral gauge. The face points up the hand rather than at the reader, which is\n * why the iso camera is the one it is drawn for: from there you get the band\n * and the face at once.\n *\n * The axis nothing else in the set has is the **input**. Every other\n * interactive machine maps a pointer to one scalar; this one takes the shape of\n * the gesture. A stroke is resampled by arc length, measured into a frame\n * (centroid, principal axis, spans, closure, area, circularity, corners) and\n * classified into an archetype, which is then fitted to that frame and filled\n * by a real scanline clip. All of that is `src/lib/robocn/construct.ts` — pure,\n * no React, tested on its own.\n *\n * Solved: the stroke frame, the classifier, the outline fit, the lattice, the\n * draw a construct costs, and the explode schedule. Illustrated: the archetype\n * outlines themselves — a glove is a glove because it is drawn as one — the\n * glow, the emission column, the knurl and the crystal. There is no physics on\n * a construct: nothing swings, nothing collides, nothing has mass.\n *\n * The ring is modelled once in world units — x starboard, y up, z along the\n * finger — and pushed through `robotCamera`, so all four views are the same\n * geometry. A construct is **not** projected: it is light thrown at the reader\n * and lives in the picture plane, because the stroke that made it was drawn\n * there.\n *\n * Design note: docs/construct-ring.md.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useReducedMotion, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp, lerp, type Vec2 } from \"@/lib/robocn/kinematics\"\nimport { explodeAssembly, type AssemblyPart } from \"@/lib/robocn/assembly\"\nimport {\n  classifyStroke,\n  constructArchetypes,\n  constructCost,\n  constructDetail,\n  constructLattice,\n  constructOutline,\n  constructSettle,\n  neutralFrame,\n  strokeFrame,\n  type ConstructArchetype,\n  type StrokeFrame,\n} from \"@/lib/robocn/construct\"\nimport {\n  boxCorners,\n  elevationDraft,\n  fitFrame,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotCameraAt,\n  robotSurface,\n  robotViews,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\n/** What the ring does with nobody driving it. Always includes `static`. */\nexport type ConstructRingBehavior = \"conjure\" | \"charge\" | \"flare\" | \"idle\" | \"static\"\n\nexport type { ConstructArchetype }\n\n/** What a forged stroke reports back to the caller. */\nexport interface ConstructReport {\n  archetype: ConstructArchetype\n  /** What holding it draws from the reserve, 0 to 1. */\n  cost: number\n  frame: StrokeFrame\n}\n\nconst VIEW_WIDTH = 260\nconst VIEW_HEIGHT = 260\nconst NATIVE_VIEW: RobotView = \"iso\"\n\n/** The band of the viewBox the ring itself is fitted into, at the bottom. */\nconst RING_BAND = 150\nconst RING_TOP = VIEW_HEIGHT - RING_BAND\n\n/** Where a construct sits when nobody drew one, in viewBox units. */\nconst FIELD_CENTER: Vec2 = { x: VIEW_WIDTH / 2, y: 58 }\nconst FIELD_SPAN = 86\n\n/* World units: x starboard, y up from the bench, z along the finger. */\nconst BAND_CENTER: Vec2 = { x: 0, y: 34 }\nconst BAND_OUTER = 34\nconst BAND_BORE = 23\nconst BAND_HALF = 11\nconst BAND_TOP = BAND_CENTER.y + BAND_OUTER\nconst SHOULDER_HALF = 11\nconst COLLAR_BOTTOM = BAND_TOP - 4\nconst COLLAR_TOP = BAND_TOP + 3\nconst BEZEL_BOTTOM = COLLAR_TOP\nconst BEZEL_TOP = BEZEL_BOTTOM + 10\nconst BEZEL_HALF_WIDTH = 19\nconst BEZEL_HALF_DEPTH = 12\nconst INLAY_HALF_DEPTH = 6.5\nconst LENS_BASE = BEZEL_TOP\nconst LENS_RISE = 8\nconst LENS_R = 15\n/** The face the iris is drawn on, as a world height. */\nconst LENS_FACE = LENS_BASE + LENS_RISE * 0.7\n\nconst ENVELOPE = boxCorners({ x: -48, y: 0, z: -26 }, { x: 48, y: 96, z: 26 })\n\n/** How long a conjured construct lives, in clock units. */\nconst CONSTRUCT_LIFE = 1.6\n/** How long a drawn construct takes to materialise, in clock units. */\nconst FORGE_RISE = 0.22\n/** Samples of a pointer path closer than this are the same sample. */\nconst STROKE_STEP = 2.4\n\n/** An arc of the band, from `from` to `to` degrees, at one radius. */\nconst shankArc = (from: number, to: number, radius: number, steps = 10): Vec2[] =>\n  Array.from({ length: steps }, (_, index) => {\n    const t = ((from + ((to - from) * index) / (steps - 1)) * Math.PI) / 180\n    return {\n      x: BAND_CENTER.x + Math.cos(t) * radius,\n      y: BAND_CENTER.y + Math.sin(t) * radius,\n    }\n  })\n\n/** A circle in the band's own plane, for the ring's near face. */\nconst ringFace = (radius: number, steps = 30): Vec2[] =>\n  Array.from({ length: steps }, (_, index) => {\n    const t = (index / steps) * Math.PI * 2\n    return {\n      x: BAND_CENTER.x + Math.cos(t) * radius,\n      y: BAND_CENTER.y + Math.sin(t) * radius,\n    }\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/** The teardown, in the order the ring was put together. */\nconst RING_PARTS: AssemblyPart[] = [\n  { id: \"band\", axis: { x: 0, y: -1, z: 0 }, travel: 0, order: 0 },\n  { id: \"shank-port\", axis: { x: -1, y: 0.2, z: 0 }, travel: 16, order: 1 },\n  { id: \"shank-starboard\", axis: { x: 1, y: 0.2, z: 0 }, travel: 16, order: 1 },\n  { id: \"collar\", axis: { x: 0, y: 1, z: 0 }, travel: 14, order: 2 },\n  { id: \"bezel\", axis: { x: 0, y: 1, z: 0 }, travel: 24, order: 3 },\n  { id: \"inlay-port\", axis: { x: -1, y: 0.35, z: 0 }, travel: 26, order: 4 },\n  { id: \"inlay-starboard\", axis: { x: 1, y: 0.35, z: 0 }, travel: 26, order: 4 },\n  { id: \"lens\", axis: { x: 0, y: 1, z: 0 }, travel: 40, order: 5 },\n]\n\n/* -------------------------------------------------------------------------- */\n/* the behaviours — pure functions of the clock                                */\n/* -------------------------------------------------------------------------- */\n\n/** The reserve the ring holds at `clock` with nobody driving it, 0 to 1. */\nexport function constructRingReserve(\n  behavior: ConstructRingBehavior,\n  clock: number,\n): number {\n  if (!Number.isFinite(clock)) return 0.7\n  const t = ((clock % 1) + 1) % 1\n  switch (behavior) {\n    case \"charge\":\n      return clamp(0.18 + t * 0.82, 0, 1)\n    case \"flare\":\n      return clamp(0.72 + Math.sin(clock * Math.PI * 2) * 0.24, 0, 1)\n    case \"idle\":\n      return clamp(0.56 + Math.sin(clock * Math.PI * 2) * 0.07, 0, 1)\n    case \"conjure\":\n      return clamp(0.94 - t * 0.18, 0, 1)\n    default:\n      return 0.7\n  }\n}\n\n/**\n * The construct the ring is holding at `clock`: which archetype, and how solid\n * it is. `charge` and `idle` forge nothing, so they report a null archetype.\n */\nexport function constructRingConjuring(\n  behavior: ConstructRingBehavior,\n  clock: number,\n): { archetype: ConstructArchetype | null; settle: number; index: number } {\n  if (behavior === \"charge\" || behavior === \"idle\") {\n    return { archetype: null, settle: 0, index: 0 }\n  }\n  const time = Number.isFinite(clock) ? clock : 0\n  if (behavior === \"static\") {\n    return { archetype: \"bubble\", settle: 1, index: 0 }\n  }\n  if (behavior === \"flare\") {\n    return { archetype: \"glove\", settle: 1, index: 3 }\n  }\n  const step = Math.floor(time / CONSTRUCT_LIFE)\n  const index = ((step % constructArchetypes.length) + constructArchetypes.length) %\n    constructArchetypes.length\n  const age = time - step * CONSTRUCT_LIFE\n  return { archetype: constructArchetypes[index], settle: constructSettle(age, CONSTRUCT_LIFE), index }\n}\n\n/**\n * Where a conjured construct stands, when no stroke drew one. Deterministic in\n * the archetype's own index, so the same clock always draws the same picture.\n */\nexport function conjuredFrame(index: number): StrokeFrame {\n  const i = Number.isFinite(index) ? index : 0\n  const along = FIELD_SPAN + (i % 3) * 13\n  const across = along / (1.08 + (i % 4) * 0.2)\n  return {\n    ...neutralFrame(along),\n    center: { x: FIELD_CENTER.x + Math.sin(i * 2.1) * 16, y: FIELD_CENTER.y + Math.cos(i * 1.7) * 9 },\n    angle: ((i * 43) % 90) - 45,\n    along,\n    across,\n    aspect: along / across,\n  }\n}\n\n/** An angle wrapped into (-180, 180]. */\nconst wrap180 = (degrees: number) => {\n  const value = Number.isFinite(degrees) ? degrees : 0\n  const wrapped = ((((value + 180) % 360) + 360) % 360) - 180\n  return wrapped === -180 ? 180 : wrapped\n}\n\n/**\n * Any pair of angles, put back on the sphere. Azimuth wraps, and an elevation\n * dragged past the pole carries on *over* it — the camera comes down the far\n * side, which is azimuth turned half a turn and the elevation mirrored, rather\n * than stopping dead at the top. The camera has no roll axis, so it arrives\n * upright rather than upside down; nothing else about the machine cares.\n */\nexport function normalizeOrbit(azimuth: number, elevation: number) {\n  let tilt = wrap180(elevation)\n  let turn = Number.isFinite(azimuth) ? azimuth : 0\n  if (tilt > 90) {\n    tilt = 180 - tilt\n    turn += 180\n  } else if (tilt < -90) {\n    tilt = -180 - tilt\n    turn += 180\n  }\n  return { azimuth: wrap180(turn), elevation: tilt }\n}\n\n/** Degrees of turn per view unit of pointer travel. */\nconst ORBIT_RATE = 1.6\n\n/* -------------------------------------------------------------------------- */\n/* the component                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport interface ConstructRingProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\" | \"onSelect\">,\n    RobotPaletteProps {\n  /** Controlled reserve, 0 spent to 1 charged. Supplying it stops the loop. */\n  reserve?: number\n  onReserveChange?: (reserve: number) => void\n  /** Controlled construct. `null` holds none; supplying it stops the cycle. */\n  construct?: ConstructArchetype | null\n  /** Fires whenever a stroke is forged into a construct. */\n  onConstructChange?: (report: ConstructReport) => void\n  behavior?: ConstructRingBehavior\n  /** Take the ring apart, 0 seated to 1 clear. */\n  exploded?: number\n  /** Let the reader draw constructs with a pointer, a finger, or Enter. */\n  drawable?: boolean\n  /** The field the constructs are forged in: a frame and its corner ticks. */\n  showField?: boolean\n  showGround?: boolean\n  /** Where the camera stands. Defaults to the view the ring was drawn in. */\n  view?: RobotView\n  /**\n   * Controlled camera, in degrees. Supplying either one turns the ring to that\n   * angle and `view` only names the drawing plane. Azimuth wraps at 360;\n   * elevation carries over the pole rather than stopping at it.\n   */\n  azimuth?: number\n  elevation?: number\n  onOrbitChange?: (orbit: { azimuth: number; elevation: number }) => void\n  /** Turn the ring with a drag, a finger, or the arrow keys. */\n  rotatable?: boolean\n  speed?: number\n  phase?: number\n  paused?: boolean\n  animate?: boolean\n  /** Makes the ring a slider over its own reserve. */\n  interactive?: boolean\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction ConstructRing({\n  reserve,\n  onReserveChange,\n  construct,\n  onConstructChange,\n  behavior = \"conjure\",\n  exploded = 0,\n  drawable = false,\n  showField = true,\n  showGround = true,\n  view = NATIVE_VIEW,\n  azimuth,\n  elevation,\n  onOrbitChange,\n  rotatable = true,\n  speed = 0.5,\n  phase = 0,\n  paused = false,\n  animate = true,\n  interactive = false,\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}: ConstructRingProps) {\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 [stroke, setStroke] = React.useState<Vec2[] | null>(null)\n  const [drawn, setDrawn] = React.useState<\n    { archetype: ConstructArchetype; frame: StrokeFrame; born: number } | null\n  >(null)\n  const [keyed, setKeyed] = React.useState(0)\n  const [turned, setTurned] = React.useState<{ azimuth: number; elevation: number } | null>(null)\n\n  const controlled = reserve !== undefined\n  const hold = controlled ? (Number.isFinite(reserve) ? clamp(reserve as number, 0, 1) : 0) : held\n\n  // Reduced motion parks the loop, so it counts as \"not running\": a parked\n  // clock would otherwise leave the construct at the start of its rise, which\n  // is nothing at all.\n  const reduced = useReducedMotion()\n  const running = animate && !reduced && !controlled && behavior !== \"static\"\n  const goal = React.useCallback(\n    (clock: number) => constructRingReserve(behavior, clock),\n    [behavior],\n  )\n  const motion = useRobotScalar(goal, {\n    rate: 0.9,\n    hold,\n    speed,\n    paused,\n    phase,\n    animate: running,\n  })\n  const clock = Number.isFinite(motion.clock) ? motion.clock : 0\n  // The clock a stroke was forged at, read in a handler rather than in render.\n  const clockRef = React.useRef(clock)\n  React.useEffect(() => {\n    clockRef.current = clock\n  }, [clock])\n\n  /* Which construct is on: a drawn one wins over a controlled one, which wins\n     over the behaviour's own cycle. */\n  const conjured = constructRingConjuring(behavior, clock)\n  const pinned = construct !== undefined\n  let archetype: ConstructArchetype | null = null\n  let frame: StrokeFrame = conjuredFrame(conjured.index)\n  let settle = 0\n  if (drawn) {\n    archetype = drawn.archetype\n    frame = drawn.frame\n    settle = running ? clamp((clock - drawn.born) / FORGE_RISE, 0, 1) : 1\n  } else if (pinned) {\n    archetype = construct\n    frame = conjuredFrame(constructArchetypes.indexOf(construct as ConstructArchetype))\n    settle = 1\n  } else {\n    archetype = conjured.archetype\n    settle = running ? conjured.settle : conjured.archetype ? 1 : 0\n  }\n  if (archetype && !constructArchetypes.includes(archetype)) archetype = \"bubble\"\n\n  const outline = archetype ? constructOutline(archetype, frame) : []\n  const lattice = archetype\n    ? constructLattice(outline, { spacing: Math.max(5, frame.along / 9), angle: frame.angle + 34 })\n    : []\n  const seams = archetype ? constructDetail(archetype, frame) : []\n  const cost = archetype ? constructCost(outline) : 0\n\n  // Charge spent is charge gone: the gauge reads the construct it is holding.\n  const base = clamp(motion.value, 0, 1)\n  const level = clamp(base - cost * settle * 0.55, 0, 1)\n\n  /* The camera. A controlled angle wins; then whatever the reader dragged it\n     to; then the named view, so a ring nobody has touched is byte-identical to\n     the one before it could be turned. */\n  const native = robotViews[view] ?? robotViews[NATIVE_VIEW]\n  const commanded =\n    azimuth !== undefined || elevation !== undefined\n      ? normalizeOrbit(azimuth ?? native.azimuth, elevation ?? native.elevation)\n      : null\n  const orbit = commanded ?? turned\n  const camera = orbit ? robotCameraAt(orbit.azimuth, orbit.elevation, view) : robotCamera(view)\n  const fit = fitFrame(ENVELOPE, camera, VIEW_WIDTH, RING_BAND)\n  const draft = elevationDraft(camera, \"front\")\n  const { path: line, solid, box, bar, disc } = draft\n\n  /** A world point on the ring, in viewBox units — where the emission starts. */\n  const toViewBox = (point: Vec2) => {\n    const screen = fit.toViewBox(draft.point(point))\n    return { x: screen.x, y: screen.y + RING_TOP }\n  }\n\n  /* The teardown. A world offset projects to a pure screen offset, so one\n     schedule serves all four cameras. */\n  const teardown = clamp(Number.isFinite(exploded) ? exploded : 0, 0, 1)\n  const parts = explodeAssembly(RING_PARTS, teardown, { overlap: 0.5 })\n  const offsets = Object.fromEntries(parts.map((part) => [part.id, part.offset])) as Record<\n    string,\n    { x: number; y: number; z: number }\n  >\n  const at = (id: string, point: Vec2): Vec2 => {\n    const offset = offsets[id] ?? { x: 0, y: 0, z: 0 }\n    return { x: point.x + offset.x, y: point.y + offset.y }\n  }\n  const lift = (id: string) => offsets[id]?.y ?? 0\n  const slide = (id: string) => offsets[id]?.x ?? 0\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Math.round(clamp(next, 0, 1) * 100) / 100\n      setHeld(bounded)\n      onReserveChange?.(bounded)\n    },\n    [onReserveChange],\n  )\n\n  /**\n   * Turn the camera to an angle, wrapped onto the sphere. The gesture keeps its\n   * own running angles in `rawRef`, because a drag can deliver several moves\n   * inside one task and a turn computed from the last *rendered* angle would\n   * throw most of them away.\n   */\n  const turn = React.useCallback(\n    (nextAzimuth: number, nextElevation: number) => {\n      const next = normalizeOrbit(nextAzimuth, nextElevation)\n      setTurned(next)\n      onOrbitChange?.(next)\n    },\n    [onOrbitChange],\n  )\n\n  // The plain slider path, for a ring that is not a sketch surface.\n  const sliding = useRobotDrag(svgRef, {\n    enabled: interactive && !drawable && !rotatable,\n    onDrag: React.useCallback((unit: Vec2) => apply(1 - unit.y), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  /* Drawing. The same gesture as the slider, told apart by where it starts:\n     inside the ring adjusts the reserve, outside it forges. */\n  const mode = React.useRef<\"draw\" | \"reserve\" | \"orbit\" | null>(null)\n  /** Where the orbiting pointer was last frame, in viewBox units. */\n  const lastPoint = React.useRef<Vec2 | null>(null)\n  /**\n   * The angles the gesture is working from, ahead of the next render and\n   * *before* they are put back on the sphere. Keeping them raw is what lets a\n   * drag carry on over the pole: once past 90 the wrapped elevation comes back\n   * down, so a gesture that read the wrapped value would bounce off the top.\n   */\n  const rawRef = React.useRef<{ azimuth: number; elevation: number } | null>(null)\n  /* The stroke lives in a ref as well as in state: several pointer events can\n     land in one task, and React would batch the renders away. The ref is what\n     the gesture reads; the state is what the drawing reads. */\n  const strokeRef = React.useRef<Vec2[]>([])\n  const toView = React.useCallback((event: React.PointerEvent): Vec2 => {\n    const rect = svgRef.current?.getBoundingClientRect()\n    if (!rect || rect.width < 1 || rect.height < 1) return { x: 0, y: 0 }\n    return {\n      x: ((event.clientX - rect.left) / rect.width) * VIEW_WIDTH,\n      y: ((event.clientY - rect.top) / rect.height) * VIEW_HEIGHT,\n    }\n  }, [])\n\n  const forge = React.useCallback(\n    (points: Vec2[]) => {\n      const measured = strokeFrame(points)\n      const verdict = classifyStroke(measured)\n      setDrawn({ archetype: verdict.archetype, frame: measured, born: clockRef.current })\n      onConstructChange?.({\n        archetype: verdict.archetype,\n        cost: constructCost(constructOutline(verdict.archetype, measured)),\n        frame: measured,\n      })\n    },\n    [onConstructChange],\n  )\n\n  const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {\n    if (!drawable && !rotatable) return\n    const point = toView(event)\n    /* One gesture, three jobs, told apart by where it starts and what is on:\n       in the field with `drawable` it forges; anywhere with `rotatable` it\n       turns the ring; and failing both, on the ring it scrubs the reserve. */\n    if (drawable && point.y <= RING_TOP) {\n      mode.current = \"draw\"\n      strokeRef.current = [point]\n      setStroke(strokeRef.current)\n    } else if (rotatable) {\n      mode.current = \"orbit\"\n      lastPoint.current = point\n      rawRef.current = orbit ?? { ...native }\n    } else if (interactive) {\n      mode.current = \"reserve\"\n      apply(clamp(1 - (point.y - RING_TOP) / RING_BAND, 0, 1))\n    } else {\n      return\n    }\n    // Capture is a convenience: a pointer id the browser does not know about\n    // (a synthetic event, a test) must not take the gesture down with it.\n    try {\n      event.currentTarget.setPointerCapture?.(event.pointerId)\n    } catch {\n      /* no capture; the gesture still tracks on the element */\n    }\n  }\n\n  const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {\n    if (!mode.current) return\n    const point = toView(event)\n    if (mode.current === \"reserve\") {\n      apply(clamp(1 - (point.y - RING_TOP) / RING_BAND, 0, 1))\n      return\n    }\n    if (mode.current === \"orbit\") {\n      // Turn by how far the pointer moved, not by where it is: a drag can go\n      // round and round without ever running out of canvas.\n      const from = lastPoint.current ?? point\n      const here = rawRef.current ?? orbit ?? native\n      const next = {\n        azimuth: here.azimuth + (point.x - from.x) * ORBIT_RATE,\n        elevation: here.elevation - (point.y - from.y) * ORBIT_RATE,\n      }\n      lastPoint.current = point\n      rawRef.current = next\n      turn(next.azimuth, next.elevation)\n      return\n    }\n    const last = strokeRef.current.at(-1)\n    if (last && Math.hypot(point.x - last.x, point.y - last.y) < STROKE_STEP) return\n    strokeRef.current = [...strokeRef.current, point]\n    setStroke(strokeRef.current)\n  }\n\n  const endStroke = (event: React.PointerEvent<SVGSVGElement>) => {\n    if (!mode.current) return\n    try {\n      event.currentTarget.releasePointerCapture?.(event.pointerId)\n    } catch {\n      /* it was never captured */\n    }\n    const path = strokeRef.current\n    lastPoint.current = null\n    if (mode.current === \"orbit\") {\n      rawRef.current = null\n    } else if (mode.current === \"reserve\") setHeld(null)\n    else if (path.length > 1) forge(path)\n    else if (path.length === 1) {\n      // A tap is a gesture too: forge something small where it landed.\n      forge([path[0], { x: path[0].x + 26, y: path[0].y + 18 }])\n    }\n    mode.current = null\n    strokeRef.current = []\n    setStroke(null)\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 lit = variant === \"outline\" || variant === \"wire\"\n\n  const percent = Math.round(level * 100)\n  const source = toViewBox({ x: 0, y: LENS_FACE + lift(\"lens\") })\n  const bloom = 0.35 + level * 0.65\n\n  /* The construct, materialising about its own centre. */\n  const swell = lerp(0.84, 1, settle)\n  const constructTransform = `translate(${px(frame.center.x)} ${px(frame.center.y)}) scale(${px(swell)}) translate(${px(-frame.center.x)} ${px(-frame.center.y)})`\n  const outlinePath = outline.length\n    ? `${outline.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")} Z`\n    : \"\"\n\n  /** The emission column, from the lens face to the construct it is holding. */\n  const beam = (() => {\n    if (!archetype || settle <= 0.02) return null\n    const target = frame.center\n    const dx = target.x - source.x\n    const dy = target.y - source.y\n    const span = Math.hypot(dx, dy)\n    if (!(span > 1)) return null\n    const nx = -dy / span\n    const ny = dx / span\n    const root = 3.5\n    const mouth = Math.max(8, frame.across * 0.34) * settle\n    return `M ${px(source.x + nx * root)} ${px(source.y + ny * root)} L ${px(target.x + nx * mouth)} ${px(target.y + ny * mouth)} L ${px(target.x - nx * mouth)} ${px(target.y - ny * mouth)} L ${px(source.x - nx * root)} ${px(source.y - ny * root)} Z`\n  })()\n\n  /** The iris on the lens face: bore, four radial inlays, and the gauge arc. */\n  const gauge = (() => {\n    const sweep = clamp(level, 0, 1) * 300\n    const start = -240\n    const end = start + sweep\n    const r = 11.5\n    const point = (degrees: number) => ({\n      x: Math.cos((degrees * Math.PI) / 180) * r,\n      y: Math.sin((degrees * Math.PI) / 180) * r,\n    })\n    const a = point(start)\n    const b = point(end)\n    if (sweep < 1) return \"\"\n    return `M ${px(a.x)} ${px(a.y)} A ${r} ${r} 0 ${sweep > 180 ? 1 : 0} 1 ${px(b.x)} ${px(b.y)}`\n  })()\n\n  const lensDome = Array.from({ length: 15 }, (_, index) => {\n    const t = (index / 14) * Math.PI\n    return { x: Math.cos(Math.PI - t) * LENS_R, y: LENS_BASE + Math.sin(t) * LENS_RISE }\n  })\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive || rotatable ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Construct ring, ${percent} percent reserve, ${\n          archetype ? `holding a ${archetype} construct` : \"holding no construct\"\n        }${teardown > 0.01 ? `, ${Math.round(teardown * 100)} percent apart` : \"\"}, ${\n          orbit\n            ? `turned to ${px(orbit.azimuth)} degrees round and ${px(orbit.elevation)} degrees up`\n            : (viewNames[view] ?? viewNames.iso)\n        }`\n      }\n      /* Turning is the axis a person reaches for first, so when the ring can be\n         turned that is the value the slider reports; the reserve stays a prop. */\n      aria-valuemin={rotatable ? -180 : interactive ? 0 : undefined}\n      aria-valuemax={rotatable ? 180 : interactive ? 1 : undefined}\n      aria-valuenow={\n        rotatable ? px(orbit?.azimuth ?? native.azimuth) : interactive ? px(level) : undefined\n      }\n      aria-valuetext={\n        rotatable\n          ? `${px(orbit?.azimuth ?? native.azimuth)} degrees round, ${px(\n              orbit?.elevation ?? native.elevation,\n            )} degrees up`\n          : interactive\n            ? `${percent} percent reserve`\n            : undefined\n      }\n      tabIndex={tabIndex ?? (interactive || drawable || rotatable ? 0 : undefined)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (event.defaultPrevented) return\n        // Enter forges the next archetype, so the feature has a keyboard path.\n        if (drawable && (event.key === \"Enter\" || event.key === \" \")) {\n          const next = (keyed + 1) % constructArchetypes.length\n          setKeyed(next)\n          const chosen = constructArchetypes[next]\n          setDrawn({ archetype: chosen, frame: conjuredFrame(next), born: clockRef.current })\n          onConstructChange?.({\n            archetype: chosen,\n            cost: constructCost(constructOutline(chosen, conjuredFrame(next))),\n            frame: conjuredFrame(next),\n          })\n          event.preventDefault()\n          return\n        }\n        if (drawable && (event.key === \"Escape\" || event.key === \"Backspace\")) {\n          setDrawn(null)\n          event.preventDefault()\n          return\n        }\n        if (rotatable) {\n          // Arrows walk the camera round and over the ring; Home puts it back\n          // on the named view.\n          const step = event.shiftKey ? 45 : 15\n          const here = orbit ?? native\n          if (event.key === \"ArrowLeft\") turn(here.azimuth - step, here.elevation)\n          else if (event.key === \"ArrowRight\") turn(here.azimuth + step, here.elevation)\n          else if (event.key === \"ArrowUp\") turn(here.azimuth, here.elevation + step * 0.7)\n          else if (event.key === \"ArrowDown\") turn(here.azimuth, here.elevation - step * 0.7)\n          else if (event.key === \"Home\") {\n            rawRef.current = null\n            setTurned(null)\n            onOrbitChange?.({ ...native })\n          } else return\n          event.preventDefault()\n          return\n        }\n        if (!interactive) return\n        const delta = arrowStep(event.key, 0.05, 0.2)\n        if (delta !== 0) apply(level + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(1)\n        else return\n        event.preventDefault()\n      }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endStroke}\n      onPointerCancel={endStroke}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!sliding && mode.current === null) setHeld(null)\n      }}\n      className={cn(\n        \"max-w-full select-none\",\n        (interactive || drawable || rotatable) &&\n          \"touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        (rotatable || (interactive && !drawable)) && \"cursor-grab\",\n        sliding && \"cursor-grabbing\",\n        drawable && \"cursor-crosshair\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      {/* The field the constructs are forged in. */}\n      {showField && (\n        <g data-field opacity={variant === \"blueprint\" ? 0.65 : 0.4}>\n          <rect\n            x={10}\n            y={10}\n            width={VIEW_WIDTH - 20}\n            height={RING_TOP - 22}\n            rx={6}\n            fill=\"none\"\n            stroke={palette.grid}\n            strokeWidth={0.6}\n            strokeDasharray=\"4 6\"\n          />\n          {[\n            [10, 10],\n            [VIEW_WIDTH - 10, 10],\n            [10, RING_TOP - 12],\n            [VIEW_WIDTH - 10, RING_TOP - 12],\n          ].map(([x, y]) => (\n            <path\n              key={`${x}:${y}`}\n              d={`M ${x - 5} ${y} H ${x + 5} M ${x} ${y - 5} V ${y + 5}`}\n              stroke={palette.grid}\n              strokeWidth={0.8}\n            />\n          ))}\n        </g>\n      )}\n\n      {/* The emission column, from the lens to whatever it is holding. */}\n      {beam && (\n        <path\n          data-emission\n          d={beam}\n          fill={palette.glow}\n          opacity={0.06 + settle * bloom * 0.14}\n          stroke=\"none\"\n        />\n      )}\n\n      {/* The construct itself: picture-plane light, not a projected solid. */}\n      {archetype && settle > 0.01 && outlinePath && (\n        <g\n          data-construct\n          data-archetype={archetype}\n          data-settle={px(settle)}\n          transform={constructTransform}\n          opacity={px(clamp(settle, 0, 1))}\n        >\n          <path\n            d={outlinePath}\n            fill={lit ? \"none\" : palette.glow}\n            opacity={lit ? 1 : 0.2 + bloom * 0.16}\n            stroke=\"none\"\n          />\n          <g data-lattice opacity={0.5 + bloom * 0.3}>\n            {lattice.map(([a, b], index) => (\n              <path\n                key={index}\n                d={`M ${px(a.x)} ${px(a.y)} L ${px(b.x)} ${px(b.y)}`}\n                stroke={palette.accent}\n                strokeWidth={1.2}\n                strokeLinecap=\"round\"\n                opacity={0.45 + (index % 3) * 0.14}\n              />\n            ))}\n          </g>\n          <g data-seams opacity={0.7}>\n            {seams.map((seam, index) => (\n              <path\n                key={index}\n                d={seam.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")}\n                fill=\"none\"\n                stroke={palette.accent}\n                strokeWidth={1.4}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            ))}\n          </g>\n          <path\n            d={outlinePath}\n            fill=\"none\"\n            stroke={palette.accent}\n            strokeWidth={2}\n            strokeLinejoin=\"round\"\n          />\n          <path\n            d={outlinePath}\n            fill=\"none\"\n            stroke={palette.glow}\n            strokeWidth={5}\n            strokeLinejoin=\"round\"\n            opacity={0.18 + bloom * 0.2}\n          />\n        </g>\n      )}\n\n      {/* The stroke under the pointer, before it is forged. */}\n      {stroke && stroke.length > 1 && (\n        <path\n          data-stroke\n          d={stroke.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")}\n          fill=\"none\"\n          stroke={palette.accent}\n          strokeWidth={2}\n          strokeDasharray=\"5 4\"\n          strokeLinecap=\"round\"\n          opacity={0.85}\n        />\n      )}\n\n      <g\n        data-view={view}\n        data-azimuth={px(orbit?.azimuth ?? native.azimuth)}\n        data-elevation={px(orbit?.elevation ?? native.elevation)}\n        transform={`translate(0 ${RING_TOP})`}\n      >\n        <g transform={fit.transform || undefined}>\n          {showGround && (\n            <>\n              <path\n                data-ground\n                d={solid([{ x: -30, y: 0.5 }, { x: 30, y: 0.5 }], 16)}\n                fill={palette.dark}\n                opacity={0.14}\n              />\n              <path\n                d={line([{ x: -44, y: 0 }, { x: 44, y: 0 }])}\n                fill=\"none\"\n                stroke={palette.dark}\n                strokeWidth={1}\n                opacity={0.45}\n              />\n            </>\n          )}\n\n          {/* The band: a knurled hoop with a bore straight through it. */}\n          <g data-band>\n            {/* The whole cylinder, then the bore behind it, then the near face,\n               so the hole is a hole rather than a disc painted on. */}\n            <path d={disc(BAND_CENTER, BAND_OUTER, BAND_HALF, 0, 30)} {...machined} />\n            <path\n              d={disc(BAND_CENTER, BAND_BORE, BAND_HALF * 0.98, 0, 26)}\n              {...cast}\n              opacity={variant === \"solid\" ? 0.82 : undefined}\n            />\n            {/* The far wall of the bore, catching what light there is. */}\n            <path\n              d={line(ringFace(BAND_BORE - 0.6), BAND_HALF * 0.92, true)}\n              fill=\"none\"\n              stroke={palette.metal}\n              strokeWidth={1.6}\n              opacity={0.55}\n            />\n            <path\n              d={`${line(ringFace(BAND_OUTER), -BAND_HALF, true)} ${line(\n                [...ringFace(BAND_BORE)].reverse(),\n                -BAND_HALF,\n                true,\n              )}`}\n              fillRule=\"evenodd\"\n              {...machined}\n            />\n            {Array.from({ length: 26 }, (_, index) => {\n              const t = (index / 26) * Math.PI * 2\n              const inner = {\n                x: BAND_CENTER.x + Math.cos(t) * (BAND_BORE + 2.5),\n                y: BAND_CENTER.y + Math.sin(t) * (BAND_BORE + 2.5),\n              }\n              const outer = {\n                x: BAND_CENTER.x + Math.cos(t) * (BAND_OUTER - 2),\n                y: BAND_CENTER.y + Math.sin(t) * (BAND_OUTER - 2),\n              }\n              return (\n                <path\n                  key={index}\n                  d={line([inner, outer], -BAND_HALF)}\n                  fill=\"none\"\n                  stroke={palette.dark}\n                  strokeWidth={0.7}\n                  opacity={0.3}\n                />\n              )\n            })}\n          </g>\n\n          {/* The shank plates: the flanks of the band, proud of it. */}\n          {([-1, 1] as const).map((side) => {\n            const id = side < 0 ? \"shank-port\" : \"shank-starboard\"\n            const from = side < 0 ? 104 : 14\n            const to = side < 0 ? 166 : 76\n            const plate = [\n              ...shankArc(from, to, BAND_OUTER - 0.6),\n              ...shankArc(to, from, BAND_BORE + 3),\n            ].map((point) => at(id, point))\n            return (\n              <g key={id} data-shank={side < 0 ? \"port\" : \"starboard\"}>\n                <path d={solid(plate, BAND_HALF * 1.04)} {...shell} />\n                <path\n                  d={line(\n                    shankArc(from + 8, to - 8, (BAND_OUTER + BAND_BORE) / 2 + 1).map((point) =>\n                      at(id, point),\n                    ),\n                    -BAND_HALF * 1.05,\n                  )}\n                  fill=\"none\"\n                  stroke={palette.dark}\n                  strokeWidth={0.8}\n                  opacity={0.45}\n                />\n              </g>\n            )\n          })}\n\n          {/* Collar, bezel plate and the two crystal inlays that flank it. */}\n          <g data-collar>\n            <path\n              d={box(-21, COLLAR_BOTTOM + lift(\"collar\"), 21, COLLAR_TOP + lift(\"collar\"), 13)}\n              {...cast}\n            />\n          </g>\n\n          <g data-bezel>\n            <path\n              d={box(\n                -BEZEL_HALF_WIDTH,\n                BEZEL_BOTTOM + lift(\"bezel\"),\n                BEZEL_HALF_WIDTH,\n                BEZEL_TOP + lift(\"bezel\"),\n                BEZEL_HALF_DEPTH,\n              )}\n              {...shell}\n            />\n          </g>\n\n          {([-1, 1] as const).map((side) => {\n            const id = side < 0 ? \"inlay-port\" : \"inlay-starboard\"\n            const dx = slide(id)\n            const dy = lift(id)\n            return (\n              <g key={id} data-inlay={side < 0 ? \"port\" : \"starboard\"}>\n                <path\n                  d={box(\n                    side < 0 ? -27 + dx : 19 + dx,\n                    BEZEL_BOTTOM + 1.5 + dy,\n                    side < 0 ? -19 + dx : 27 + dx,\n                    BEZEL_TOP - 0.5 + dy,\n                    INLAY_HALF_DEPTH,\n                  )}\n                  fill={palette.accent}\n                  stroke={palette.dark}\n                  strokeWidth={0.8}\n                  opacity={0.55 + bloom * 0.45}\n                />\n              </g>\n            )\n          })}\n\n          {/* The lens: a dome, and the iris drawn on its face. */}\n          <g data-lens>\n            <path\n              d={solid(\n                lensDome.map((p) => at(\"lens\", p)),\n                BEZEL_HALF_DEPTH * 0.86,\n              )}\n              fill={palette.glow}\n              stroke={palette.accent}\n              strokeWidth={1.2}\n              opacity={0.3 + bloom * 0.5}\n            />\n            <g\n              data-bore\n              transform={camera.plane(LENS_FACE + lift(\"lens\")) || undefined}\n              opacity={0.85}\n            >\n              <circle r={13} fill={palette.dark} opacity={0.75} />\n              <circle r={13} fill=\"none\" stroke={palette.metal} strokeWidth={1.4} />\n              {/* The spiral in the bore — abstract, and not a mark of anything. */}\n              <path\n                d={Array.from({ length: 40 }, (_, index) => {\n                  const t = (index / 39) * Math.PI * 3.4\n                  const r = 2 + (index / 39) * 8.4\n                  return `${index ? \"L\" : \"M\"} ${px(Math.cos(t) * r)} ${px(Math.sin(t) * r)}`\n                }).join(\" \")}\n                fill=\"none\"\n                stroke={palette.accent}\n                strokeWidth={0.9}\n                opacity={0.4 + bloom * 0.4}\n              />\n              <circle r={4.6} fill={palette.accent} opacity={0.35 + bloom * 0.55} />\n              {[0, 90, 180, 270].map((degrees) => {\n                const radians = (degrees * Math.PI) / 180\n                return (\n                  <path\n                    key={degrees}\n                    d={`M ${px(Math.cos(radians) * 5.4)} ${px(Math.sin(radians) * 5.4)} L ${px(Math.cos(radians) * 12)} ${px(Math.sin(radians) * 12)}`}\n                    stroke={palette.glow}\n                    strokeWidth={1.6}\n                    strokeLinecap=\"round\"\n                    opacity={0.45 + bloom * 0.45}\n                  />\n                )\n              })}\n              {gauge && (\n                <path\n                  data-gauge\n                  d={gauge}\n                  fill=\"none\"\n                  stroke={palette.accent}\n                  strokeWidth={1.8}\n                  strokeLinecap=\"round\"\n                />\n              )}\n            </g>\n          </g>\n\n          {variant === \"blueprint\" && (\n            <text\n              x={px(draft.point({ x: 44, y: BEZEL_TOP }).x)}\n              y={px(draft.point({ x: 44, y: BEZEL_TOP }).y)}\n              textAnchor=\"start\"\n              fontFamily=\"ui-monospace, monospace\"\n              fontSize={6}\n              fill={palette.foreground}\n            >\n              {`${percent}%`}\n            </text>\n          )}\n        </g>\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 6}\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\nexport { ConstructRing }\n",
      "type": "registry:ui",
      "target": "@ui/construct-ring.tsx"
    }
  ],
  "categories": [
    "robotics",
    "fabrication"
  ],
  "type": "registry:ui"
}