shadimage
uicapsule
blockchibi-plantssource: ok · hit

Chibi Plants

npx shadcn add "https://uicapsule.com/r/chibi-plants.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
webglthree

Files3 · source cached 10/15/2026, 3:34:15 AM

/chibi-plants.tsx
"use client";

import { useEffect, useRef } from "react";
import * as THREE from "three/webgpu";
import {
  Fn,
  If,
  Break,
  Loop,
  uniform,
  uv,
  float,
  vec2,
  vec3,
  mix,
  clamp,
  smoothstep,
  length,
  normalize,
  dot,
  pow,
  exp,
  abs,
  min,
  max,
  sin,
  sqrt,
} from "three/tsl";

import type { Mat3, PlantHit, PlantPart, RigFrame, Vec3 } from "./chibi-rig";
import {
  BLEND_HI,
  BLEND_LO,
  PINCH_E1,
  PINCH_E2,
  POT_H,
  POT_R,
  PlantRig,
  SOIL_Y,
  gazeMatrix,
} from "./chibi-rig";

/**
 * Chibi plants raymarched from signed distance fields — the same technique as
 * tobis.vision's "critters": every part (pot, body, leaves, arms) is an SDF
 * primitive smooth-blended into one continuous soft-vinyl surface, rendered
 * with three.js TSL on the WebGPU renderer (transparent WebGL2 fallback).
 * The face is painted in the shader; the whole plant turns toward your cursor,
 * its eyes lead the head, it blinks, and switching characters morphs the
 * surface rather than swapping models.
 *
 * It is also a soft toy you can handle: a small position-based soft body
 * (`chibi-rig.ts`) carries a rigid pot, a squishy body and springy leaves, and
 * the shader evaluates the SDF through the transforms fitted to it. Pinch and
 * pull the body, drag it by the pot, lift it by a leaf, toss it, tap it.
 */

export const CHIBI_VARIANTS = ["pip", "momo", "fifi", "kiki"] as const;
export type ChibiVariant = (typeof CHIBI_VARIANTS)[number];

export interface ChibiPlantsProps {
  /** Which plant character to show. Switching morphs the SDF surface. @default "pip" */
  variant?: ChibiVariant;
  /** Override the pot color (CSS hex). Defaults to the variant's palette. */
  potColor?: string;
  /** Override the body color (CSS hex). Defaults to the variant's palette. */
  bodyColor?: string;
  /** Override the leaf/sprout color (CSS hex). Defaults to the variant's palette. */
  leafColor?: string;
  /** Blush color. @default "#e8798f" */
  cheekColor?: string;
  /** Background radial-gradient stops [inner, outer]. @default ["#2e2a33", "#0b0a0e"] */
  background?: [string, string];
  /** Eye size multiplier. @default 1 */
  eyeScale?: number;
  /** How strongly the head + eyes follow the cursor, 0–1.5. @default 1 */
  gaze?: number;
  /** Idle sway/breathing amount, 0–2. @default 1 */
  wobble?: number;
  /** Idle animation speed multiplier. @default 1 */
  speed?: number;
  /** Grab, pull, toss and tap the plant. @default true */
  interactive?: boolean;
  /** How firm the body is, 0 (mochi) – 1 (vinyl). @default 0.5 */
  firmness?: number;
  /** Raymarch step count (read once at mount). Lower on weak GPUs. @default 96 */
  raySteps?: number;
  className?: string;
}

/** Morphable SDF + face parameters. Every key is spring-lerped, so any two
 * variants (or user tweaks) blend as one continuous surface deformation.
 * The key list is the single source of truth: PlantParams derives from it. */
const PARAM_KEYS = [
  "bodyR",
  "squash",
  "stemH",
  "l0s",
  "l0yaw",
  "l0tilt",
  "l0len",
  "l0wid",
  "l1s",
  "l1yaw",
  "l1tilt",
  "l1len",
  "l1wid",
  "l2s",
  "l2yaw",
  "l2tilt",
  "l2len",
  "l2wid",
  "armS",
  "eyeR",
  "eyeSep",
  "eyeY",
  "mouthW",
  "cheek",
] as const;
type PlantParams = Record<(typeof PARAM_KEYS)[number], number>;

interface Palette {
  body: string;
  leaf: string;
  pot: string;
}
interface VariantDef {
  label: string;
  params: PlantParams;
  palette: Palette;
}

export const VARIANTS = {
  fifi: {
    label: "Fifi",
    palette: { body: "#7cc487", leaf: "#4e9e63", pot: "#e8e3da" },
    params: {
      armS: 0.001,
      bodyR: 0.34,
      cheek: 0.55,
      eyeR: 0.15,
      eyeSep: 0.33,
      eyeY: 0.22,
      l0len: 0.24,
      l0s: 1,
      l0tilt: 0.85,
      l0wid: 0.12,
      l0yaw: 0,
      l1len: 0.22,
      l1s: 1,
      l1tilt: 1.1,
      l1wid: 0.11,
      l1yaw: 2.1,
      l2len: 0.22,
      l2s: 1,
      l2tilt: 1.1,
      l2wid: 0.11,
      l2yaw: -2.1,
      mouthW: 1,
      squash: 1.12,
      stemH: 0.12,
    },
  },
  kiki: {
    label: "Kiki",
    palette: { body: "#7fae72", leaf: "#f193b4", pot: "#e2986a" },
    params: {
      armS: 1,
      bodyR: 0.32,
      cheek: 0.7,
      eyeR: 0.17,
      eyeSep: 0.27,
      eyeY: 0.12,
      l0len: 0.12,
      l0s: 0.7,
      l0tilt: 0.15,
      l0wid: 0.08,
      l0yaw: 0,
      l1len: 0.1,
      l1s: 0.001,
      l1tilt: 0.9,
      l1wid: 0.07,
      l1yaw: -1.57,
      l2len: 0.1,
      l2s: 0.001,
      l2tilt: 0.9,
      l2wid: 0.07,
      l2yaw: 1.57,
      mouthW: 0.7,
      squash: 1.5,
      stemH: 0.03,
    },
  },
  momo: {
    label: "Momo",
    palette: { body: "#f2a7bb", leaf: "#7cbf80", pot: "#ead9c8" },
    params: {
      armS: 0.001,
      bodyR: 0.43,
      cheek: 1,
      eyeR: 0.185,
      eyeSep: 0.3,
      eyeY: 0.02,
      l0len: 0.15,
      l0s: 0.85,
      l0tilt: 0.55,
      l0wid: 0.09,
      l0yaw: 0.3,
      l1len: 0.12,
      l1s: 0.001,
      l1tilt: 0.9,
      l1wid: 0.08,
      l1yaw: -1.57,
      l2len: 0.12,
      l2s: 0.001,
      l2tilt: 0.9,
      l2wid: 0.08,
      l2yaw: 1.57,
      mouthW: 0.55,
      squash: 0.78,
      stemH: 0.04,
    },
  },
  pip: {
    label: "Pip",
    palette: { body: "#9fd6a3", leaf: "#67b26f", pot: "#d98b71" },
    params: {
      armS: 0.001,
      bodyR: 0.34,
      cheek: 0.8,
      eyeR: 0.155,
      eyeSep: 0.27,
      eyeY: 0.06,
      l0len: 0.17,
      l0s: 1,
      l0tilt: 0.95,
      l0wid: 0.1,
      l0yaw: 1.57,
      l1len: 0.17,
      l1s: 1,
      l1tilt: 0.95,
      l1wid: 0.1,
      l1yaw: -1.57,
      l2len: 0.12,
      l2s: 0.001,
      l2tilt: 0.5,
      l2wid: 0.08,
      l2yaw: 0,
      mouthW: 0.8,
      squash: 0.96,
      stemH: 0.3,
    },
  },
} satisfies Record<ChibiVariant, VariantDef>;

const DEFAULT_BG: [string, string] = ["#2e2a33", "#0b0a0e"];

// Camera: a fixed pinhole looking slightly down at the plant.
const RO = new THREE.Vector3(0, 0.62, 2.35);
const TA = new THREE.Vector3(0, 0.52, 0);
const FW = TA.clone().sub(RO).normalize();
const RI = FW.clone()
  .cross(new THREE.Vector3(0, 1, 0))
  .normalize();
const UP = RI.clone().cross(FW);
const FOV_S = 0.36;
/**
 * Narrow (portrait) viewports widen the field of view until this much of the table is visible
 * either side of the plant, so it fits a phone instead of spilling off both edges.
 */
const FIT_HALF_WIDTH = 0.58;
const fovFor = (aspect: number) => Math.max(FOV_S, FIT_HALF_WIDTH / (RO.z * aspect));
/** A tap shorter and stiller than this is a boop, not a grab. */
const TAP_MS = 280;
const TAP_PX = 6;

const posePlant = (p: PlantParams, gaze: Mat3) => ({
  armS: p.armS,
  bodyR: p.bodyR,
  gaze,
  leaves: [
    { len: p.l0len, s: p.l0s, tilt: p.l0tilt, wid: p.l0wid, yaw: p.l0yaw },
    { len: p.l1len, s: p.l1s, tilt: p.l1tilt, wid: p.l1wid, yaw: p.l1yaw },
    { len: p.l2len, s: p.l2s, tilt: p.l2tilt, wid: p.l2wid, yaw: p.l2yaw },
  ] as const,
  squash: p.squash,
  stemH: p.stemH,
});

/** The camera ray through a viewport point, as the fragment shader builds it. */
const rayThrough = (ndcX: number, ndcY: number, aspect: number) => {
  const fov = fovFor(aspect);
  const rd = FW.clone()
    .addScaledVector(RI, ndcX * aspect * fov)
    .addScaledVector(UP, ndcY * fov)
    .normalize();
  return { rd: [rd.x, rd.y, rd.z] satisfies Vec3, ro: [RO.x, RO.y, RO.z] satisfies Vec3 };
};

/** Where a world point lands in the viewport, ndc. */
const project = (p: Vec3, aspect: number) => {
  const v = new THREE.Vector3(...p).sub(RO);
  const depth = Math.max(v.dot(FW), 1e-3);
  const fov = fovFor(aspect);
  return [v.dot(RI) / depth / fov / aspect, v.dot(UP) / depth / fov] as const;
};

export const ChibiPlants = ({
  variant = "pip",
  potColor,
  bodyColor,
  leafColor,
  cheekColor = "#e8798f",
  background = DEFAULT_BG,
  eyeScale = 1,
  gaze = 1,
  wobble = 1,
  speed = 1,
  interactive = true,
  firmness = 0.5,
  raySteps = 96,
  className,
}: ChibiPlantsProps) => {
  const containerRef = useRef<HTMLDivElement>(null);
  // Live tuning values, read every frame by the render loop.
  const tuningRef = useRef({
    background,
    bodyColor,
    cheekColor,
    eyeScale,
    firmness,
    gaze,
    interactive,
    leafColor,
    potColor,
    speed,
    variant,
    wobble,
  });
  useEffect(() => {
    tuningRef.current = {
      background,
      bodyColor,
      cheekColor,
      eyeScale,
      firmness,
      gaze,
      interactive,
      leafColor,
      potColor,
      speed,
      variant,
      wobble,
    };
  }, [
    variant,
    potColor,
    bodyColor,
    leafColor,
    cheekColor,
    background,
    eyeScale,
    gaze,
    wobble,
    speed,
    interactive,
    firmness,
  ]);
  const stepsRef = useRef(raySteps);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) {
      return;
    }
    let disposed = false;
    let raf = 0;

    const renderer = new THREE.WebGPURenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    renderer.domElement.style.width = "100%";
    renderer.domElement.style.height = "100%";
    renderer.domElement.style.display = "block";
    // The plant is dragged around; the page must not scroll or zoom under the finger.
    renderer.domElement.style.touchAction = "none";

    // Start from the requested variant and colors — the morph springs are for
    // later changes, not the initial mount.
    const initialTuning = tuningRef.current;
    const initialVariant = VARIANTS[initialTuning.variant] ?? VARIANTS.pip;
    const initialParams = {
      ...initialVariant.params,
      eyeR: initialVariant.params.eyeR * initialTuning.eyeScale,
    } satisfies PlantParams;
    const initialBody = initialTuning.bodyColor ?? initialVariant.palette.body;
    const initialLeaf = initialTuning.leafColor ?? initialVariant.palette.leaf;
    const initialPot = initialTuning.potColor ?? initialVariant.palette.pot;

    // ---- physics -------------------------------------------------------------
    const rig = new PlantRig(posePlant(initialParams, gazeMatrix(0, 0)));
    const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
    let frame: RigFrame = rig.frame();

    // ---- uniforms ----------------------------------------------------------
    const uTime = uniform(0);
    const uLook = uniform(new THREE.Vector2(0, 0));
    const uBlink = uniform(0);
    const uExcite = uniform(0);
    const uSquint = uniform(0);
    const uGaze = uniform(1);
    const uWobble = uniform(1);
    const uRes = uniform(new THREE.Vector2(1, 1));
    const uFov = uniform(FOV_S);
    const uBg1 = uniform(new THREE.Color(initialTuning.background[0]));
    const uBg2 = uniform(new THREE.Color(initialTuning.background[1]));
    const uBodyC = uniform(new THREE.Color(initialBody));
    const uLeafC = uniform(new THREE.Color(initialLeaf));
    const uPotC = uniform(new THREE.Color(initialPot));
    const uCheekC = uniform(new THREE.Color(initialTuning.cheekColor));

    // Rig transforms (see RigFrame): world → plant space via the pot, world →
    // design space via the body, plus the stem, leaves and pinch.
    const uPotM = uniform(new THREE.Matrix3());
    const uPotK = uniform(new THREE.Vector3());
    const uBodyM = uniform(new THREE.Matrix3());
    const uBodyK = uniform(new THREE.Vector3());
    const uStemTip = uniform(new THREE.Vector3());
    const uLeafBase = uniform(new THREE.Vector3());
    const uLeafM = [
      uniform(new THREE.Matrix3()),
      uniform(new THREE.Matrix3()),
      uniform(new THREE.Matrix3()),
    ] as const;
    const uPinchC = uniform(new THREE.Vector3());
    const uPinchD = uniform(new THREE.Vector3());
    const uPinchOn = uniform(0);
    const uPinchL = uniform(0);
    const uWarp = uniform(1);
    const uGap = uniform(0);
    const uBound = uniform(new THREE.Vector4(0, 0.58, 0, 1.32));
    const uShadow = uniform(new THREE.Vector4(0, 0, 1, 1));

    const uParams = {
      armS: uniform(initialParams.armS),
      bodyR: uniform(initialParams.bodyR),
      cheek: uniform(initialParams.cheek),
      eyeR: uniform(initialParams.eyeR),
      eyeSep: uniform(initialParams.eyeSep),
      eyeY: uniform(initialParams.eyeY),
      l0len: uniform(initialParams.l0len),
      l0s: uniform(initialParams.l0s),
      l0tilt: uniform(initialParams.l0tilt),
      l0wid: uniform(initialParams.l0wid),
      l0yaw: uniform(initialParams.l0yaw),
      l1len: uniform(initialParams.l1len),
      l1s: uniform(initialParams.l1s),
      l1tilt: uniform(initialParams.l1tilt),
      l1wid: uniform(initialParams.l1wid),
      l1yaw: uniform(initialParams.l1yaw),
      l2len: uniform(initialParams.l2len),
      l2s: uniform(initialParams.l2s),
      l2tilt: uniform(initialParams.l2tilt),
      l2wid: uniform(initialParams.l2wid),
      l2yaw: uniform(initialParams.l2yaw),
      mouthW: uniform(initialParams.mouthW),
      squash: uniform(initialParams.squash),
      stemH: uniform(initialParams.stemH),
    };

    const upload = (f: RigFrame) => {
      uPotM.value.set(...f.potM);
      uPotK.value.set(...f.potK);
      uBodyM.value.set(...f.bodyM);
      uBodyK.value.set(...f.bodyK);
      uStemTip.value.set(...f.stemTip);
      uLeafBase.value.set(...f.leafBase);
      uLeafM[0].value.set(...f.leafM[0]);
      uLeafM[1].value.set(...f.leafM[1]);
      uLeafM[2].value.set(...f.leafM[2]);
      uPinchC.value.set(...f.pinchC);
      uPinchD.value.set(...f.pinchD);
      uPinchOn.value = Math.hypot(...f.pinchD) > 1e-4 ? 1 : 0;
      uPinchL.value = (1.3 * Math.hypot(...f.pinchD)) / PINCH_E1;
      uWarp.value = f.warp;
      uGap.value = f.gap;
      uBound.value.set(...f.bound);
      const [sx, sz, lift] = f.shadow;
      uShadow.value.set(sx, sz, 1 / (1 + lift * 5), 1 + lift * 1.4);
    };
    upload(frame);

    // ---- TSL node helpers --------------------------------------------------
    type Scalar = THREE.Node<"float">;
    type Point = THREE.Node<"vec3">;
    const smin = (a: Scalar, b: Scalar, k: number) => {
      const h = clamp(
        b
          .sub(a)
          .mul(0.5 / k)
          .add(0.5),
        0,
        1,
      );
      return mix(b, a, h).sub(h.mul(h.oneMinus()).mul(k));
    };
    const sdEllipsoid = (p: Point, r: Point) => {
      const k0 = length(p.div(r));
      const k1 = length(p.div(r.mul(r)));
      return k0.mul(k0.sub(1)).div(k1.add(1e-6));
    };
    const sdVCapsule = (p: Point, h: Scalar, r: Scalar) =>
      length(vec3(p.x, p.y.sub(clamp(p.y, 0, h)), p.z)).sub(r);
    const sdCapsule = (p: Point, a: Point, b: Point, r: number) => {
      const pa = p.sub(a).toVar();
      const ba = b.sub(a).toVar();
      const h = clamp(dot(pa, ba).div(max(dot(ba, ba), 1e-8)), 0, 1);
      return length(pa.sub(ba.mul(h))).sub(r);
    };

    // Breathing + idle sway, all driven by uTime so the plant is alive at rest.
    const breathe = () => sin(uTime.mul(2.1)).mul(uWobble).toVar();

    // The pinch: a bi-scale regularised Kelvinlet (de Goes & James 2017),
    // normalised so the grabbed spot moves by exactly uPinchD. Incompressible,
    // so pulling a cheek out draws the flesh around it in.
    const kelvinlet = (r: Point, eps: number) => {
      const re2 = dot(r, r)
        .add(eps * eps)
        .toVar();
      const re = sqrt(re2).toVar();
      const re3 = re2.mul(re).toVar();
      const a = float(1)
        .div(re)
        .add(float(eps * eps).div(re3));
      return uPinchD.mul(a.mul(0.5)).add(r.mul(dot(r, uPinchD).div(re3).mul(0.5)));
    };
    const pinch = (x: Point) => {
      const r = x.sub(uPinchC).toVar();
      return kelvinlet(r, PINCH_E1)
        .sub(kelvinlet(r, PINCH_E2))
        .mul(1 / (1 / PINCH_E1 - 1 / PINCH_E2));
    };

    // World → design space. Below the rim the body rides with the pot, above
    // it with the soft body; the pinch is undone last (first-order inverse).
    // `k` scales design-space distances back to a safe world-space step: the
    // body frame's stretch, the shear across the rim band where the two frames
    // disagree, and the pinch — the last two only where they actually are.
    const bodyCenterY = () => uParams.bodyR.mul(uParams.squash).mul(0.55).add(POT_H).toVar();
    const toDesign = (p: Point) => {
      const xp = uPotM.mul(p).add(uPotK).toVar();
      const xb = uBodyM.mul(p).add(uBodyK);
      const t = clamp(xp.y.sub(BLEND_LO).div(BLEND_HI - BLEND_LO), 0, 1).toVar();
      const x = mix(xp, xb, t.mul(t).mul(t.mul(-2).add(3))).toVar();
      const stretch = t
        .mul(t.oneMinus())
        .mul(uGap.mul(6 / (BLEND_HI - BLEND_LO)))
        .add(1)
        .mul(uWarp)
        .toVar();
      If(uPinchOn.greaterThan(0.5), () => {
        const r = x.sub(uPinchC);
        const e2 = PINCH_E1 * PINCH_E1;
        stretch.mulAssign(uPinchL.mul(float(e2).div(dot(r, r).add(e2))).add(1));
        x.subAssign(pinch(x));
      });
      return { k: float(1).div(stretch).toVar(), q: x.sub(vec3(0, bodyCenterY(), 0)).toVar(), xp };
    };

    // The pot is its own rigid thing: a rounded, tapered tub filled to the soil
    // line, with a lip standing proud of the soil that the plant grows out of.
    const sdPot = (p: Point) => {
      const py = p.y.sub(POT_H * 0.5);
      const ra = float(POT_R).mul(p.y.div(POT_H).sub(0.5).mul(0.3).add(1));
      const dx = length(vec2(p.x, p.z)).sub(ra).add(0.03);
      const dy = abs(py).sub(POT_H * 0.5 - 0.02);
      const d2 = vec2(dx, dy).toVar();
      const tub = max(
        min(max(d2.x, d2.y), 0)
          .add(length(max(d2, vec2(0, 0))))
          .sub(0.03),
        p.y.sub(SOIL_Y),
      );
      const rim = length(vec2(length(vec2(p.x, p.z)).sub(POT_R * 1.1), p.y.sub(POT_H))).sub(0.038);
      return smin(tub, rim, 0.02).toVar();
    };

    // One leaf, given the sample point already in its frame (rest yaw/tilt ×
    // swing, from the rig): an ellipsoid blade.
    const sdLeaf = (lq: Point, s: Scalar, len: Scalar, wid: Scalar) => {
      const ll = len.mul(s).toVar();
      const ww = wid.mul(s).toVar();
      return sdEllipsoid(
        vec3(lq.x, lq.y.sub(ll.mul(0.55).add(0.02)), lq.z),
        vec3(ww, ll.mul(0.6), ww.mul(0.45)),
      );
    };

    // Part distances in body-centred design space; combined by map(),
    // re-queried at the hit point for smooth part-color weights (the "one
    // continuous surface" look). Scaled by k so the warp stays a safe step.
    const plantParts = (q: Point, k: Scalar) => {
      const br = breathe();
      const rB = uParams.bodyR.mul(br.mul(-0.012).add(1)).toVar();
      const sq = uParams.squash.mul(br.mul(0.02).add(1)).toVar();
      const dBody = sdEllipsoid(q, vec3(rB, rB.mul(sq), rB)).toVar();

      const bodyTop = rB.mul(sq).sub(0.03).toVar();
      const dStem = sdCapsule(q, vec3(0, bodyTop, 0), uStemTip, 0.035).toVar();
      const knobR = clamp(uParams.stemH.mul(10), 0, 1).mul(0.05);
      const dKnob = length(q.sub(uStemTip)).sub(knobR);
      const lq = q.sub(uLeafBase).toVar();
      const dL0 = sdLeaf(uLeafM[0].mul(lq), uParams.l0s, uParams.l0len, uParams.l0wid);
      const dL1 = sdLeaf(uLeafM[1].mul(lq), uParams.l1s, uParams.l1len, uParams.l1wid);
      const dL2 = sdLeaf(uLeafM[2].mul(lq), uParams.l2s, uParams.l2len, uParams.l2wid);
      const dGreen = min(min(smin(dStem, dKnob, 0.03), dL0), min(dL1, dL2)).toVar();

      const aq = vec3(abs(q.x), q.y, q.z).toVar();
      const armR = uParams.armS.mul(0.095).toVar();
      const armH = uParams.armS.mul(0.26).toVar();
      const dArm = sdVCapsule(
        vec3(aq.x.sub(rB.mul(0.92)), aq.y.add(rB.mul(sq).mul(0.05)), aq.z),
        armH,
        armR,
      ).toVar();

      return {
        dArm: dArm.mul(k).toVar(),
        dBody: dBody.mul(k).toVar(),
        dGreen: dGreen.mul(k).toVar(),
      };
    };

    // The plant is one continuous soft surface; the pot is a separate solid it
    // is planted in, so the two meet in a hard crease, never a fillet.
    const plantSdf = (dBody: Scalar, dGreen: Scalar, dArm: Scalar) =>
      smin(smin(dBody, dGreen, 0.045), dArm, 0.07);
    const sceneSdf = (p: Point) => {
      const { k, q, xp } = toDesign(p);
      const { dBody, dGreen, dArm } = plantParts(q, k);
      return min(sdPot(xp), plantSdf(dBody, dGreen, dArm)).toVar();
    };

    const mapFn = Fn(([p]: [Point]) => sceneSdf(p));

    const calcNormal = (p: Point) => {
      const h = 0.0045;
      const e1 = vec3(1, -1, -1);
      const e2 = vec3(-1, -1, 1);
      const e3 = vec3(-1, 1, -1);
      const e4 = vec3(1, 1, 1);
      return normalize(
        e1
          .mul(mapFn(p.add(e1.mul(h))))
          .add(e2.mul(mapFn(p.add(e2.mul(h)))))
          .add(e3.mul(mapFn(p.add(e3.mul(h)))))
          .add(e4.mul(mapFn(p.add(e4.mul(h))))),
      ).toVar();
    };

    // ---- fragment ----------------------------------------------------------
    const fragment = Fn(() => {
      const aspect = uRes.x.div(uRes.y);
      const ndc = uv().mul(2).sub(1).toVar();
      const nx = ndc.x.mul(aspect).toVar();
      const ro = vec3(RO.x, RO.y, RO.z);
      const rd = normalize(
        vec3(RI.x, RI.y, RI.z)
          .mul(nx.mul(uFov))
          .add(vec3(UP.x, UP.y, UP.z).mul(ndc.y.mul(uFov)))
          .add(vec3(FW.x, FW.y, FW.z)),
      ).toVar();

      // Background: radial dusk gradient + soft contact shadow on the floor,
      // under the pot, fading and spreading as it is lifted.
      const bgT = smoothstep(0.05, 1.25, length(vec2(nx, ndc.y.mul(1.15).add(0.12))));
      const col = mix(uBg1.rgb, uBg2.rgb, bgT).toVar();
      const tg = ro.y.sub(0.001).div(max(rd.y.negate(), 1e-4)).toVar();
      const gp = ro.add(rd.mul(tg)).toVar();
      const shadowR = length(vec2(gp.x.sub(uShadow.x), gp.z.sub(uShadow.y).mul(1.35))).div(
        uShadow.w,
      );
      const shadow = exp(shadowR.mul(shadowR).mul(-6))
        .mul(0.42)
        .mul(uShadow.z)
        .mul(smoothstep(0, 0.02, rd.y.negate()));
      col.assign(col.mul(shadow.oneMinus()));

      // Bounding-sphere pre-test so empty pixels stay cheap; it follows the plant.
      const oc = ro.sub(uBound.xyz).toVar();
      const bq = dot(oc, rd).toVar();
      const bh = bq
        .mul(bq)
        .sub(dot(oc, oc).sub(uBound.w.mul(uBound.w)))
        .toVar();

      If(bh.greaterThan(0), () => {
        const tStart = max(bq.negate().sub(sqrt(bh)), 0).toVar();
        const tEnd = bq.negate().add(sqrt(bh)).toVar();
        const t = tStart.toVar();
        const hit = float(0).toVar();

        Loop(stepsRef.current, () => {
          const pos = ro.add(rd.mul(t));
          const d = mapFn(pos).toVar();
          If(d.lessThan(0.0013), () => {
            hit.assign(1);
            Break();
          });
          t.addAssign(d.mul(0.85));
          If(t.greaterThan(tEnd), () => {
            Break();
          });
        });

        If(hit.greaterThan(0.5), () => {
          const p = ro.add(rd.mul(t)).toVar();
          const n = calcNormal(p);
          const { k, q, xp } = toDesign(p);
          const { dBody, dGreen, dArm } = plantParts(q, k);
          const dPot = sdPot(xp);

          // Plant parts share soft weights — colors bleed across their smin
          // creases. The pot is a different object: a hard edge, no bleed, and
          // the soil is the top of its fill, inside the lip.
          const wBody = exp(dBody.div(-0.028)).toVar();
          const wGreen = exp(dGreen.div(-0.028)).toVar();
          const wArm = exp(dArm.div(-0.028)).toVar();
          const wSum = wBody.add(wGreen).add(wArm).toVar();
          const plantC = uBodyC.rgb
            .mul(wBody)
            .add(uLeafC.rgb.mul(wGreen))
            .add(uBodyC.rgb.mul(0.92).mul(wArm))
            .div(wSum);
          const onPot = smoothstep(-0.002, 0.002, plantSdf(dBody, dGreen, dArm).sub(dPot)).toVar();
          const onSoil = onPot
            .mul(smoothstep(0.292, 0.275, length(vec2(xp.x, xp.z))))
            .mul(smoothstep(0.014, 0.004, abs(xp.y.sub(SOIL_Y))))
            .toVar();
          const albedo = mix(
            plantC,
            mix(uPotC.rgb, vec3(0.12, 0.08, 0.055), onSoil),
            onPot,
          ).toVar();
          const bodyW = wBody.div(wSum).mul(onPot.oneMinus()).toVar();

          // ---- face, painted on the front of the body ----
          const sq = uParams.squash;
          const nd = normalize(q.div(vec3(1, sq, 1))).toVar();
          const front = smoothstep(0.05, 0.4, nd.z)
            .mul(smoothstep(0.55, 0.75, bodyW))
            .toVar();
          const fu = nd.x.toVar();
          const fv = nd.y.toVar();
          const leadX = uLook.x.mul(0.2).mul(uGaze).toVar();
          const leadY = uLook.y.mul(0.16).mul(uGaze).toVar();

          // A squint first shuts the eyes like a blink, then they scrunch into > <.
          const shut = max(uBlink, smoothstep(0, 0.55, uSquint)).toVar();
          const scrunch = smoothstep(0.45, 0.8, uSquint).toVar();
          const eyeRr = uParams.eyeR
            .mul(uExcite.mul(0.3).add(1))
            .mul(shut.mul(0.3).oneMinus())
            .toVar();
          const blinkK = float(1).div(shut.mul(0.94).oneMinus()).toVar();
          const eyeMask = float(0).toVar();
          const hlMask = float(0).toVar();
          for (const sign of [-1, 1]) {
            const c = vec2(uParams.eyeSep.mul(sign).add(leadX), uParams.eyeY.add(leadY)).toVar();
            const dv = vec2(fu.sub(c.x), fv.sub(c.y).mul(blinkK)).toVar();
            const disc = smoothstep(eyeRr, eyeRr.sub(0.015), length(dv)).mul(scrunch.oneMinus());
            // Squeezed shut: a chevron pointing at the nose, > <.
            const ex = fu.sub(c.x).mul(-sign);
            const ey = abs(fv.sub(c.y));
            const a = uParams.eyeR.mul(0.55);
            const tip = vec2(a, 0);
            const arm = vec2(a.negate(), uParams.eyeR.mul(0.6));
            const pa = vec2(ex, ey).sub(tip).toVar();
            const ba = arm.sub(tip).toVar();
            const hh = clamp(dot(pa, ba).div(dot(ba, ba)), 0, 1);
            const th = uParams.eyeR.mul(0.17);
            const chevron = smoothstep(th, th.sub(0.012), length(pa.sub(ba.mul(hh))));
            eyeMask.assign(max(eyeMask, max(disc, chevron.mul(scrunch))));
            const h1 = vec2(fu.sub(c.x).add(eyeRr.mul(0.3)), fv.sub(c.y).sub(eyeRr.mul(0.34)));
            hlMask.assign(
              max(hlMask, smoothstep(eyeRr.mul(0.3), eyeRr.mul(0.3).sub(0.012), length(h1))),
            );
            const h2 = vec2(fu.sub(c.x).sub(eyeRr.mul(0.28)), fv.sub(c.y).add(eyeRr.mul(0.3)));
            hlMask.assign(
              max(hlMask, smoothstep(eyeRr.mul(0.14), eyeRr.mul(0.14).sub(0.012), length(h2))),
            );
          }
          hlMask.assign(hlMask.mul(eyeMask).mul(shut.oneMinus()));

          const mw = uParams.mouthW.mul(uExcite.mul(0.5).add(1)).mul(0.058).toVar();
          const mdv = vec2(
            fu.sub(leadX.mul(0.6)),
            fv.sub(uParams.eyeY).add(0.14).sub(leadY.mul(0.5)).mul(1.4),
          ).toVar();
          const mouthMask = smoothstep(mw, mw.sub(0.012), length(mdv))
            .mul(eyeMask.oneMinus())
            .toVar();

          const cheekMask = float(0).toVar();
          for (const sign of [-1, 1]) {
            const cc = vec2(
              uParams.eyeSep.add(uParams.eyeR).add(0.09).mul(sign),
              uParams.eyeY.sub(0.1),
            );
            cheekMask.assign(
              max(cheekMask, smoothstep(0.085, 0.02, length(vec2(fu.sub(cc.x), fv.sub(cc.y))))),
            );
          }

          const blush = uParams.cheek.mul(uSquint.mul(0.6).add(1));
          albedo.assign(mix(albedo, uCheekC.rgb, cheekMask.mul(blush).mul(0.5).mul(front)));
          albedo.assign(mix(albedo, vec3(0.16, 0.11, 0.09), eyeMask.mul(front)));
          albedo.assign(mix(albedo, vec3(0.28, 0.13, 0.11), mouthMask.mul(front)));
          albedo.assign(mix(albedo, vec3(0.95, 0.95, 0.97), hlMask.mul(front)));

          // ---- shading: soft vinyl plant, glazed ceramic pot, matte soil ----
          const ld = normalize(vec3(0.55, 0.75, 0.5));
          const dif = clamp(dot(n, ld).mul(0.5).add(0.5), 0, 1).toVar();
          const skyT = n.y.mul(0.5).add(0.5);
          const amb = mix(vec3(0.3, 0.27, 0.33), vec3(0.56, 0.58, 0.67), skyT).toVar();
          const ao1 = clamp(mapFn(p.add(n.mul(0.05))).div(0.05), 0, 1);
          const ao2 = clamp(mapFn(p.add(n.mul(0.16))).div(0.16), 0, 1);
          const ao = ao1.mul(0.55).add(ao2.mul(0.45)).mul(0.7).add(0.3).toVar();
          const lit = albedo.mul(amb.add(dif.mul(vec3(0.98, 0.9, 0.8)).mul(1.05)).mul(ao)).toVar();
          const hv = normalize(ld.sub(rd));
          const gloss = onPot.mul(onSoil.oneMinus()).toVar();
          const spec = pow(clamp(dot(n, hv), 0, 1), mix(50, 140, gloss))
            .mul(mix(0.55, 0.9, gloss))
            .mul(onSoil.oneMinus())
            .mul(ao);
          const fres = pow(clamp(dot(n, rd.negate()), 0, 1).oneMinus(), 3.5)
            .mul(mix(0.3, 0.18, gloss))
            .mul(onSoil.oneMinus());
          lit.assign(
            lit
              .add(vec3(1, 0.98, 0.95).mul(spec))
              .add(mix(uBg1.rgb, vec3(0.6, 0.62, 0.7), 0.5).mul(fres)),
          );

          col.assign(lit);
        });
      });

      return vec3(col);
    });

    const material = new THREE.MeshBasicNodeMaterial();
    material.colorNode = fragment();
    const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
    const scene = new THREE.Scene();
    scene.add(quad);
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

    // ---- animation state ---------------------------------------------------
    const cur = { ...initialParams };
    const vel = { ...initialParams };
    const target = { ...initialParams };
    for (const key of PARAM_KEYS) {
      vel[key] = 0;
    }
    const curCol = {
      body: new THREE.Color(initialBody),
      leaf: new THREE.Color(initialLeaf),
      pot: new THREE.Color(initialPot),
    };
    const tgtCol = { body: new THREE.Color(), leaf: new THREE.Color(), pot: new THREE.Color() };

    const look = { tx: 0, ty: 0.1, vx: 0, vy: 0, x: 0, y: 0 };
    let lastPointer = -1e4;
    let nextWander = 0;
    let blink = 0;
    let blinkTarget = 0;
    let nextBlink = 1.2;
    let excite = 0;
    let squint = 0;
    let squintHold = 0;
    let elapsed = 0;
    let lastVariant = initialTuning.variant;
    // Pointer in viewport ndc (null until it moves over the window), and the
    // head's own screen position, so the gaze stays aimed at the cursor
    // wherever the plant has been dragged.
    let pointer: readonly [number, number] | null = null;
    let head: readonly [number, number] = [0, 0];

    const aspectNow = () => {
      const rect = container.getBoundingClientRect();
      return rect.width / Math.max(rect.height, 1);
    };
    const ndcOf = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      return [
        ((e.clientX - rect.left) / rect.width) * 2 - 1,
        -(((e.clientY - rect.top) / rect.height) * 2 - 1),
      ] as const;
    };

    const onPointerMove = (e: PointerEvent) => {
      pointer = ndcOf(e);
      const speedMag = Math.hypot(e.movementX ?? 0, e.movementY ?? 0);
      excite = Math.min(1, excite + speedMag * 0.004);
      lastPointer = elapsed;
    };
    window.addEventListener("pointermove", onPointerMove);

    // ---- handling ----------------------------------------------------------
    interface Hold {
      id: number;
      part: PlantPart;
      /** Where the pointer touched, and the point being dragged (see PlantRig.grab). */
      point: Vec3;
      anchor: Vec3;
      x: number;
      y: number;
      at: number;
      moved: boolean;
    }
    let hold: Hold | null = null;
    let hover = false;

    const pickAt = (ndc: readonly [number, number]): PlantHit | null => {
      const { ro, rd } = rayThrough(ndc[0], ndc[1], aspectNow());
      return rig.pick(ro, rd, frame);
    };
    const setCursor = () => {
      let cursor = "";
      if (hold) {
        cursor = "grabbing";
      } else if (hover) {
        cursor = "grab";
      }
      renderer.domElement.style.cursor = cursor;
    };

    const onPointerDown = (e: PointerEvent) => {
      if (!tuningRef.current.interactive || hold || e.button !== 0) {
        return;
      }
      const hit = pickAt(ndcOf(e));
      if (!hit) {
        return;
      }
      e.preventDefault();
      renderer.domElement.setPointerCapture(e.pointerId);
      hold = {
        anchor: rig.grab(hit.part, hit.point),
        at: performance.now(),
        id: e.pointerId,
        moved: false,
        part: hit.part,
        point: hit.point,
        x: e.clientX,
        y: e.clientY,
      };
      setCursor();
    };
    const onCanvasMove = (e: PointerEvent) => {
      if (!hold || e.pointerId !== hold.id) {
        return;
      }
      if (Math.hypot(e.clientX - hold.x, e.clientY - hold.y) > TAP_PX) {
        hold.moved = true;
      }
      // Drag in the plane through the grabbed point, facing the camera.
      const [nx, ny] = ndcOf(e);
      const { ro, rd } = rayThrough(nx, ny, aspectNow());
      const along = new THREE.Vector3(...rd);
      const t = new THREE.Vector3(...hold.anchor).sub(RO).dot(FW) / Math.max(along.dot(FW), 1e-3);
      rig.drag([ro[0] + rd[0] * t, ro[1] + rd[1] * t, ro[2] + rd[2] * t]);
    };
    const onPointerUp = (e: PointerEvent) => {
      if (!hold || e.pointerId !== hold.id) {
        return;
      }
      rig.release();
      if (!hold.moved && performance.now() - hold.at < TAP_MS) {
        const { rd } = rayThrough(...ndcOf(e), aspectNow());
        rig.boop(hold.part, hold.point, rd);
        squintHold = 0.45;
      }
      hold = null;
      setCursor();
    };
    renderer.domElement.addEventListener("pointerdown", onPointerDown);
    renderer.domElement.addEventListener("pointermove", onCanvasMove);
    renderer.domElement.addEventListener("pointerup", onPointerUp);
    renderer.domElement.addEventListener("pointercancel", onPointerUp);
    renderer.domElement.addEventListener("lostpointercapture", onPointerUp);

    const resize = () => {
      const w = container.clientWidth || 1;
      const h = container.clientHeight || 1;
      renderer.setSize(w, h, false);
      const dpr = Math.min(window.devicePixelRatio, 1.5);
      uRes.value.set(w * dpr, h * dpr);
      // The screen edges are walls: keep the whole pot in view.
      uFov.value = fovFor(w / h);
      rig.halfWidth = Math.max(0.38, RO.z * uFov.value * (w / h) - 0.06);
    };
    const ro2 = new ResizeObserver(resize);
    ro2.observe(container);

    type Tuning = typeof tuningRef.current;

    // Variant + prop overrides drive the morph targets; a new character
    // arrives with a hop.
    const morph = (dt: number, tuning: Tuning) => {
      const v = VARIANTS[tuning.variant] ?? VARIANTS.pip;
      if (tuning.variant !== lastVariant) {
        lastVariant = tuning.variant;
        rig.hop(reducedMotion.matches ? 0 : 2);
      }
      for (const k of PARAM_KEYS) {
        target[k] = v.params[k];
      }
      target.eyeR = v.params.eyeR * tuning.eyeScale;
      tgtCol.body.set(tuning.bodyColor ?? v.palette.body);
      tgtCol.leaf.set(tuning.leafColor ?? v.palette.leaf);
      tgtCol.pot.set(tuning.potColor ?? v.palette.pot);
      uCheekC.value.set(tuning.cheekColor);
      uBg1.value.set(tuning.background[0]);
      uBg2.value.set(tuning.background[1]);

      // Slightly underdamped springs make morphs land with a squishy overshoot.
      const omega = 9;
      const zeta = 0.72;
      for (const k of PARAM_KEYS) {
        const x = cur[k] - target[k];
        const a = -omega * omega * x - 2 * zeta * omega * vel[k];
        vel[k] += a * dt;
        cur[k] += vel[k] * dt;
        uParams[k].value = cur[k];
      }
      const cf = 1 - Math.exp(-dt * 7);
      curCol.body.lerp(tgtCol.body, cf);
      curCol.leaf.lerp(tgtCol.leaf, cf);
      curCol.pot.lerp(tgtCol.pot, cf);
      uBodyC.value.copy(curCol.body);
      uLeafC.value.copy(curCol.leaf);
      uPotC.value.copy(curCol.pot);
    };

    // Gaze: at the cursor relative to the head, else an idle wander.
    const aim = (dt: number) => {
      if (pointer && elapsed - lastPointer <= 2.4) {
        look.tx = THREE.MathUtils.clamp((pointer[0] - head[0]) * 1.1, -1, 1);
        look.ty = THREE.MathUtils.clamp((pointer[1] - head[1]) * 1.1, -1, 1);
      } else if (elapsed > nextWander) {
        look.tx = (Math.random() * 2 - 1) * 0.85;
        look.ty = Math.random() * 0.9 - 0.35;
        nextWander = elapsed + 1.3 + Math.random() * 1.5;
      }
      const lo = 10;
      const lz = 0.85;
      const ax = -lo * lo * (look.x - look.tx) - 2 * lz * lo * look.vx;
      const ay = -lo * lo * (look.y - look.ty) - 2 * lz * lo * look.vy;
      look.vx += ax * dt;
      look.vy += ay * dt;
      look.x += look.vx * dt;
      look.y += look.vy * dt;
      uLook.value.set(look.x, look.y);
    };

    // The head turn (cursor follow + idle sway) is folded into the body's
    // frame on the CPU, so the leaves swing after it.
    const simulate = (dt: number, tuning: Tuning) => {
      const yaw = look.x * -0.55 * tuning.gaze + Math.sin(elapsed * 0.6) * 0.07 * tuning.wobble;
      const pitch =
        look.y * 0.38 * tuning.gaze + Math.sin(elapsed * 0.83 + 1.7) * 0.05 * tuning.wobble;
      rig.firmness = THREE.MathUtils.clamp(tuning.firmness, 0, 1);
      rig.calm = reducedMotion.matches;
      rig.setPose(posePlant(cur, gazeMatrix(yaw, pitch)));
      if (hold && !tuning.interactive) {
        rig.release();
        hold = null;
        setCursor();
      }
      rig.step(dt);
      frame = rig.frame();
      upload(frame);
      head = project(frame.head, aspectNow());
    };

    // Blinks, quick close and softer open with the odd double; wide eyes off
    // the ground; > < when squeezed, booped or landed hard.
    const emote = (dt: number) => {
      if (elapsed > nextBlink && blinkTarget === 0) {
        blinkTarget = 1;
        nextBlink = elapsed + 2 + Math.random() * 3.2 + (Math.random() < 0.18 ? -1.75 : 0);
      }
      blink += (blinkTarget - blink) * Math.min(1, dt * (blinkTarget === 1 ? 26 : 13));
      if (blink > 0.93) {
        blinkTarget = 0;
      }
      uBlink.value = blink;

      const landing = rig.takeLanding();
      if (landing > 0.2) {
        squintHold = Math.max(squintHold, 0.2 + landing * 0.35);
      }
      squintHold = Math.max(0, squintHold - dt);
      const squintTarget = squintHold > 0 || rig.strain > 0.55 ? 1 : 0;
      squint += (squintTarget - squint) * Math.min(1, dt * (squintTarget > squint ? 22 : 9));
      uSquint.value = squint;

      excite = rig.airborne ? 1 : Math.max(0, excite - dt * 1.4);
      uExcite.value = excite;
    };

    const checkHover = (tuning: Tuning) => {
      if (hold || !pointer || !tuning.interactive) {
        return;
      }
      const over = pickAt(pointer) !== null;
      if (over !== hover) {
        hover = over;
        setCursor();
      }
    };

    let prev = performance.now();
    const tick = () => {
      raf = requestAnimationFrame(tick);
      const now = performance.now();
      const dt = Math.min(0.05, (now - prev) / 1000);
      prev = now;
      const tuning = tuningRef.current;
      elapsed += dt * tuning.speed;
      uTime.value = elapsed;
      uGaze.value = tuning.gaze;
      uWobble.value = tuning.wobble;
      morph(dt, tuning);
      aim(dt);
      simulate(dt, tuning);
      emote(dt);
      checkHover(tuning);
      renderer.render(scene, camera);
    };

    container.append(renderer.domElement);
    void (async () => {
      try {
        await renderer.init();
      } catch (error) {
        console.error("chibi-plants: renderer init failed", error);
        return;
      }
      if (disposed) {
        return;
      }
      resize();
      prev = performance.now();
      raf = requestAnimationFrame(tick);
    })();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      ro2.disconnect();
      window.removeEventListener("pointermove", onPointerMove);
      renderer.domElement.removeEventListener("pointerdown", onPointerDown);
      renderer.domElement.removeEventListener("pointermove", onCanvasMove);
      renderer.domElement.removeEventListener("pointerup", onPointerUp);
      renderer.domElement.removeEventListener("pointercancel", onPointerUp);
      renderer.domElement.removeEventListener("lostpointercapture", onPointerUp);
      renderer.domElement.remove();
      quad.geometry.dispose();
      material.dispose();
      renderer.dispose();
    };
    // The shader graph is built once; live values flow through uniforms/refs.
  }, []);

  return <div ref={containerRef} className={className ?? "h-full w-full"} />;
};
/chibi-rig.ts
/**
 * The chibi plant as a little soft body: position-based dynamics at a fixed 60 Hz with 8
 * substeps, after the XPBD plush-toy sims. A rigid pot (shape matching, rotation only), a squishy
 * body (affine shape matching, volume-normalised, so a pull stretches it thin and a press bulges
 * it out), and a stem and leaves on springs that flop with inertia. Grabbing pins particles to
 * the pointer; whatever the body can't follow becomes a local pinch (a Kelvinlet in the shader).
 *
 * The raymarcher never sees a particle. Each frame the rig fits transforms to them — the pot's
 * rigid frame, the body's affine frame with the gaze folded in, a rotation per leaf — and the SDF
 * is evaluated through those. The CPU picking SDF reads the same transforms, so a grab lands on
 * exactly what is drawn.
 */

export const POT_H = 0.36;
export const POT_R = 0.3;
/** The pot is filled to here; the plant grows out of the soil, inside the lip. */
export const SOIL_Y = POT_H - 0.03;
/** Below this band the body's base rides with the pot; above it, with the soft body. */
export const BLEND_LO = POT_H - 0.04;
export const BLEND_HI = POT_H + 0.16;
/** Bi-scale Kelvinlet radii: the pinch falls off as 1/r³ instead of a lone Kelvinlet's 1/r. */
export const PINCH_E1 = 0.14;
export const PINCH_E2 = 0.28;

export type Vec3 = [number, number, number];
/** A row-major 3×3 matrix. */
export type Mat3 = [number, number, number, number, number, number, number, number, number];
type Quat = [number, number, number, number];

export type PlantPart = "body" | "leaf0" | "leaf1" | "leaf2" | "pot" | "stem";
const LEAF_PARTS = ["leaf0", "leaf1", "leaf2"] as const;

export interface LeafPose {
  s: number;
  yaw: number;
  tilt: number;
  len: number;
  wid: number;
}

/** The plant's morphable proportions this frame, plus the head turn. */
export interface PlantPose {
  bodyR: number;
  squash: number;
  stemH: number;
  armS: number;
  leaves: readonly [LeafPose, LeafPose, LeafPose];
  /** Head turn in design space, about the body centre: RX(pitch)·RY(yaw). */
  gaze: Mat3;
}

/** What the shader and the picking SDF need, fitted to the particles. */
export interface RigFrame {
  /** World → plant space through the pot's rigid frame. */
  potM: Mat3;
  potK: Vec3;
  /** World → design space through the body's affine frame, gaze folded in. */
  bodyM: Mat3;
  bodyK: Vec3;
  /** Stem tip and leaf base, body-centred design space. */
  stemTip: Vec3;
  leafBase: Vec3;
  /** Per-leaf sample-point frames: rest yaw/tilt composed with the leaf's swing. */
  leafM: [Mat3, Mat3, Mat3];
  /** Pinch centre and displacement, design space. */
  pinchC: Vec3;
  pinchD: Vec3;
  /**
   * How much the warps can stretch space, for a safe raymarch step: the body frame's largest
   * stretch (≥ 1), and how far the body and pot frames disagree across the rim band.
   */
  warp: number;
  gap: number;
  /** Raymarch bounding sphere: centre, radius. */
  bound: [number, number, number, number];
  /** Contact shadow: x, z, and the pot's height off the floor. */
  shadow: Vec3;
  /** The body's centre, world space — where the face is. */
  head: Vec3;
}

export interface PlantHit {
  part: PlantPart;
  point: Vec3;
}

// ---- small linear algebra ------------------------------------------------

const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smoothstep = (e0: number, e1: number, v: number) => {
  const t = clamp((v - e0) / (e1 - e0), 0, 1);
  return t * t * (3 - 2 * t);
};
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const addScaled = (a: Vec3, b: Vec3, s: number): Vec3 => [
  a[0] + b[0] * s,
  a[1] + b[1] * s,
  a[2] + b[2] * s,
];
const scale = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s];
const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vec3, b: Vec3): Vec3 => [
  a[1] * b[2] - a[2] * b[1],
  a[2] * b[0] - a[0] * b[2],
  a[0] * b[1] - a[1] * b[0],
];
const length = (a: Vec3) => Math.hypot(a[0], a[1], a[2]);
const normalize = (a: Vec3): Vec3 => {
  const l = length(a) || 1;
  return [a[0] / l, a[1] / l, a[2] / l];
};
const clampLength = (a: Vec3, max: number): Vec3 => {
  const l = length(a);
  return l > max ? [(a[0] / l) * max, (a[1] / l) * max, (a[2] / l) * max] : a;
};
const mulMV = (m: Mat3, v: Vec3): Vec3 => {
  const [a, b, c, d, e, f, g, h, i] = m;
  const [x, y, z] = v;
  return [a * x + b * y + c * z, d * x + e * y + f * z, g * x + h * y + i * z];
};
const mulMM = (m: Mat3, n: Mat3): Mat3 => {
  const [a, b, c, d, e, f, g, h, i] = m;
  const [j, k, l, o, p, q, r, s, t] = n;
  return [
    a * j + b * o + c * r,
    a * k + b * p + c * s,
    a * l + b * q + c * t,
    d * j + e * o + f * r,
    d * k + e * p + f * s,
    d * l + e * q + f * t,
    g * j + h * o + i * r,
    g * k + h * p + i * s,
    g * l + h * q + i * t,
  ];
};
const transpose = (m: Mat3): Mat3 => {
  const [a, b, c, d, e, f, g, h, i] = m;
  return [a, d, g, b, e, h, c, f, i];
};
const det3 = (m: Mat3) => {
  const [a, b, c, d, e, f, g, h, i] = m;
  return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
};
const inverse3 = (m: Mat3): Mat3 | null => {
  const [a, b, c, d, e, f, g, h, i] = m;
  const k0 = e * i - f * h;
  const k1 = f * g - d * i;
  const k2 = d * h - e * g;
  const det = a * k0 + b * k1 + c * k2;
  if (Math.abs(det) < 1e-9) {
    return null;
  }
  const s = 1 / det;
  return [
    k0 * s,
    (c * h - b * i) * s,
    (b * f - c * e) * s,
    k1 * s,
    (a * i - c * g) * s,
    (c * d - a * f) * s,
    k2 * s,
    (b * g - a * h) * s,
    (a * e - b * d) * s,
  ];
};
const lerpM = (m: Mat3, n: Mat3, t: number): Mat3 => [
  m[0] + (n[0] - m[0]) * t,
  m[1] + (n[1] - m[1]) * t,
  m[2] + (n[2] - m[2]) * t,
  m[3] + (n[3] - m[3]) * t,
  m[4] + (n[4] - m[4]) * t,
  m[5] + (n[5] - m[5]) * t,
  m[6] + (n[6] - m[6]) * t,
  m[7] + (n[7] - m[7]) * t,
  m[8] + (n[8] - m[8]) * t,
];
const scaleM = (m: Mat3, s: number): Mat3 => lerpM([0, 0, 0, 0, 0, 0, 0, 0, 0], m, s);

/** Rotations of a sample point, matching the shader's rotY/rotX. */
export const rotY = (a: number): Mat3 => {
  const c = Math.cos(a);
  const s = Math.sin(a);
  return [c, 0, s, 0, 1, 0, -s, 0, c];
};
export const rotX = (a: number): Mat3 => {
  const c = Math.cos(a);
  const s = Math.sin(a);
  return [1, 0, 0, 0, c, -s, 0, s, c];
};
/** The head turn as the shader used to apply it: RY(yaw) to the sample point, then RX(pitch). */
export const gazeMatrix = (yaw: number, pitch: number) => mulMM(rotX(pitch), rotY(yaw));
const rotAxis = (axis: Vec3, angle: number): Mat3 => {
  const [x, y, z] = axis;
  const c = Math.cos(angle);
  const s = Math.sin(angle);
  const t = 1 - c;
  return [
    t * x * x + c,
    t * x * y - s * z,
    t * x * z + s * y,
    t * x * y + s * z,
    t * y * y + c,
    t * y * z - s * x,
    t * x * z - s * y,
    t * y * z + s * x,
    t * z * z + c,
  ];
};
/**
 * `r` with its heading (turn about world up) taken out. The face is painted on the body, so the
 * plant always turns back to face the viewer; looking around is the gaze's job, not the body's.
 */
const faceForward = (r: Mat3): Mat3 => {
  const [fx, fy, fz] = mulMV(r, [0, 0, 1]);
  if (Math.abs(fy) > 0.9) {
    return r;
  }
  return mulMM(rotAxis([0, 1, 0], -Math.atan2(fx, fz)), r);
};

/** The shortest rotation taking unit vector `a` onto unit vector `b`. */
const rotBetween = (a: Vec3, b: Vec3): Mat3 => {
  const axis = cross(a, b);
  const s = length(axis);
  const c = dot(a, b);
  if (s < 1e-6) {
    return c > 0 ? IDENTITY : rotAxis(normalize(cross(a, [0.3, 0.1, 0.9])), Math.PI);
  }
  return rotAxis([axis[0] / s, axis[1] / s, axis[2] / s], Math.atan2(s, c));
};
const quatToMat = (q: Quat): Mat3 => {
  const [x, y, z, w] = q;
  return [
    1 - 2 * (y * y + z * z),
    2 * (x * y - w * z),
    2 * (x * z + w * y),
    2 * (x * y + w * z),
    1 - 2 * (x * x + z * z),
    2 * (y * z - w * x),
    2 * (x * z - w * y),
    2 * (y * z + w * x),
    1 - 2 * (x * x + y * y),
  ];
};
/**
 * The rotational part of `a`, warm-started from `q` (Müller et al., "A Robust Method to Extract
 * the Rotational Part of Deformations", 2016). Updates `q` in place; stable through inversion.
 */
const extractRotation = (a: Mat3, q: Quat, iterations: number): Mat3 => {
  const [a0, a1, a2, a3, a4, a5, a6, a7, a8] = a;
  for (let it = 0; it < iterations; it += 1) {
    const [r0, r1, r2, r3, r4, r5, r6, r7, r8] = quatToMat(q);
    // Σ over columns of R × A, over |Σ R·A|: the axis-angle that turns R toward A.
    const ox = r3 * a6 - r6 * a3 + (r4 * a7 - r7 * a4) + (r5 * a8 - r8 * a5);
    const oy = r6 * a0 - r0 * a6 + (r7 * a1 - r1 * a7) + (r8 * a2 - r2 * a8);
    const oz = r0 * a3 - r3 * a0 + (r1 * a4 - r4 * a1) + (r2 * a5 - r5 * a2);
    const dd =
      r0 * a0 + r3 * a3 + r6 * a6 + (r1 * a1 + r4 * a4 + r7 * a7) + (r2 * a2 + r5 * a5 + r8 * a8);
    const mag = Math.hypot(ox, oy, oz);
    const w = mag / (Math.abs(dd) + 1e-9);
    if (w < 1e-9) {
      break;
    }
    const s = Math.sin(w / 2) / mag;
    const [ax, ay, az, aw] = [ox * s, oy * s, oz * s, Math.cos(w / 2)];
    const [qx, qy, qz, qw] = q;
    const nx = aw * qx + ax * qw + ay * qz - az * qy;
    const ny = aw * qy - ax * qz + ay * qw + az * qx;
    const nz = aw * qz + ax * qy - ay * qx + az * qw;
    const nw = aw * qw - ax * qx - ay * qy - az * qz;
    const nl = 1 / Math.hypot(nx, ny, nz, nw);
    q[0] = nx * nl;
    q[1] = ny * nl;
    q[2] = nz * nl;
    q[3] = nw * nl;
  }
  return quatToMat(q);
};
/** Largest singular value of `m` (power iteration on mᵀm). */
const spectralNorm = (m: Mat3) => {
  const mtm = mulMM(transpose(m), m);
  let v: Vec3 = [0.6, 0.7, 0.4];
  for (let it = 0; it < 6; it += 1) {
    v = normalize(mulMV(mtm, v));
  }
  return Math.sqrt(Math.max(dot(v, mulMV(mtm, v)), 0));
};

// ---- the SDF, mirrored from the shader for picking ---------------------------

const smin = (a: number, b: number, k: number) => {
  const h = clamp((b - a) * (0.5 / k) + 0.5, 0, 1);
  return b + (a - b) * h - h * (1 - h) * k;
};
const sdEllipsoid = (p: Vec3, r: Vec3) => {
  const k0 = Math.hypot(p[0] / r[0], p[1] / r[1], p[2] / r[2]);
  const k1 = Math.hypot(p[0] / (r[0] * r[0]), p[1] / (r[1] * r[1]), p[2] / (r[2] * r[2]));
  return (k0 * (k0 - 1)) / (k1 + 1e-6);
};
const sdCapsule = (p: Vec3, a: Vec3, b: Vec3, r: number) => {
  const pa = sub(p, a);
  const ba = sub(b, a);
  const h = clamp(dot(pa, ba) / Math.max(dot(ba, ba), 1e-8), 0, 1);
  return length(addScaled(pa, ba, -h)) - r;
};
export const sdPot = (p: Vec3) => {
  const ra = POT_R * ((p[1] / POT_H - 0.5) * 0.3 + 1);
  const rr = Math.hypot(p[0], p[2]);
  const dx = rr - ra + 0.03;
  const dy = Math.abs(p[1] - POT_H * 0.5) - (POT_H * 0.5 - 0.02);
  const rounded = Math.min(Math.max(dx, dy), 0) + Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
  const tub = Math.max(rounded - 0.03, p[1] - SOIL_Y);
  const rim = Math.hypot(rr - POT_R * 1.1, p[1] - POT_H) - 0.038;
  return smin(tub, rim, 0.02);
};
const bodyCentreY = (pose: PlantPose) => POT_H + pose.bodyR * pose.squash * 0.55;
const bodyTop = (pose: PlantPose) => pose.bodyR * pose.squash - 0.03;
/** Rest yaw/tilt of a leaf as a sample-point frame, matching the shader's rotX(rotY(·)). */
const leafRestFrame = (leaf: LeafPose) => mulMM(rotX(-leaf.tilt), rotY(leaf.yaw));
const leafReach = (leaf: LeafPose) => leaf.len * leaf.s * 1.15 + 0.02;

// ---- particles ---------------------------------------------------------------

interface Particle {
  x: number;
  y: number;
  z: number;
  /** Position at the start of the substep. */
  px: number;
  py: number;
  pz: number;
  vx: number;
  vy: number;
  vz: number;
  /** Vertical speed coming into the substep, for the landing bounce. */
  ivy: number;
  /** Rest position, design space. */
  rx: number;
  ry: number;
  rz: number;
  m: number;
  w: number;
  /** Floor clearance and how much gravity it feels. */
  rad: number;
  grav: number;
}

const particle = (m: number, rad: number, grav: number): Particle => ({
  grav,
  ivy: 0,
  m,
  px: 0,
  py: 0,
  pz: 0,
  rad,
  rx: 0,
  ry: 0,
  rz: 0,
  vx: 0,
  vy: 0,
  vz: 0,
  w: 1 / m,
  x: 0,
  y: 0,
  z: 0,
});

const pos = (p: Particle): Vec3 => [p.x, p.y, p.z];
const restPos = (p: Particle): Vec3 => [p.rx, p.ry, p.rz];
const setRest = (p: Particle, v: Vec3) => {
  [p.rx, p.ry, p.rz] = v;
};
const nudge = (p: Particle, d: Vec3, f: number) => {
  p.x += d[0] * f;
  p.y += d[1] * f;
  p.z += d[2] * f;
};
const kick = (p: Particle, d: Vec3, s: number) => {
  p.vx += d[0] * s;
  p.vy += d[1] * s;
  p.vz += d[2] * s;
};

const centroid = (ps: readonly Particle[]): Vec3 => {
  let m = 0;
  let x = 0;
  let y = 0;
  let z = 0;
  for (const p of ps) {
    m += p.m;
    x += p.x * p.m;
    y += p.y * p.m;
    z += p.z * p.m;
  }
  return [x / m, y / m, z / m];
};
/**
 * A shape-matching cluster: its particles, each with a rest offset from the cluster's rest
 * centroid (one particle can sit in several clusters), and a warm start for the rotation.
 */
interface Cluster {
  members: { p: Particle; q: Vec3 }[];
  restC: Vec3;
  aqqInv: Mat3;
  turn: Quat;
}
const cluster = (ps: readonly Particle[]): Cluster => ({
  aqqInv: IDENTITY,
  members: ps.map((p) => ({ p, q: [0, 0, 0] })),
  restC: [0, 0, 0],
  turn: [0, 0, 0, 1],
});
/** Re-derive rest offsets (and Σ m q qᵀ) after the rest pose changed. */
const restCluster = (cl: Cluster) => {
  let m = 0;
  let c: Vec3 = [0, 0, 0];
  for (const { p } of cl.members) {
    m += p.m;
    c = addScaled(c, restPos(p), p.m);
  }
  cl.restC = scale(c, 1 / m);
  const a: Mat3 = [0, 0, 0, 0, 0, 0, 0, 0, 0];
  for (const member of cl.members) {
    const { p } = member;
    const q = sub(restPos(p), cl.restC);
    member.q = q;
    a[0] += p.m * q[0] * q[0];
    a[1] += p.m * q[0] * q[1];
    a[2] += p.m * q[0] * q[2];
    a[4] += p.m * q[1] * q[1];
    a[5] += p.m * q[1] * q[2];
    a[8] += p.m * q[2] * q[2];
  }
  [a[3], a[6], a[7]] = [a[1], a[2], a[5]];
  cl.aqqInv = inverse3(a) ?? IDENTITY;
};
/** The cluster's centroid now and Σ m (x − c) qᵀ, its deformation unnormalised. */
const measure = (cl: Cluster) => {
  let m = 0;
  let sum: Vec3 = [0, 0, 0];
  for (const { p } of cl.members) {
    m += p.m;
    sum = addScaled(sum, pos(p), p.m);
  }
  const c = scale(sum, 1 / m);
  const a: Mat3 = [0, 0, 0, 0, 0, 0, 0, 0, 0];
  for (const { p, q } of cl.members) {
    const [x, y, z] = scale(sub(pos(p), c), p.m);
    a[0] += x * q[0];
    a[1] += x * q[1];
    a[2] += x * q[2];
    a[3] += y * q[0];
    a[4] += y * q[1];
    a[5] += y * q[2];
    a[6] += z * q[0];
    a[7] += z * q[1];
    a[8] += z * q[2];
  }
  return { apq: a, c };
};

/** Distance constraint (XPBD, one iteration). `slack` lets it go only when stretched past rest. */
const solveDistance = (
  a: Particle,
  b: Particle,
  rest: number,
  compliance: number,
  slack: boolean,
) => {
  const d: Vec3 = [b.x - a.x, b.y - a.y, b.z - a.z];
  const len = length(d);
  const c = len - rest;
  if (len < 1e-9 || (slack && c <= 0)) {
    return;
  }
  const corr = c / (a.w + b.w + compliance) / len;
  nudge(a, d, corr * a.w);
  nudge(b, d, -corr * b.w);
};

/** Keep each particle's share of the group's rigid motion; scale the rest by `keep`. */
const dampDeformation = (ps: readonly Particle[], keep: number) => {
  let m = 0;
  const c: Vec3 = [0, 0, 0];
  const v: Vec3 = [0, 0, 0];
  for (const p of ps) {
    m += p.m;
    [c[0], c[1], c[2]] = [c[0] + p.x * p.m, c[1] + p.y * p.m, c[2] + p.z * p.m];
    [v[0], v[1], v[2]] = [v[0] + p.vx * p.m, v[1] + p.vy * p.m, v[2] + p.vz * p.m];
  }
  const cm: Vec3 = [c[0] / m, c[1] / m, c[2] / m];
  const vm: Vec3 = [v[0] / m, v[1] / m, v[2] / m];
  let ang: Vec3 = [0, 0, 0];
  const inertia: Mat3 = [0, 0, 0, 0, 0, 0, 0, 0, 0];
  for (const p of ps) {
    const r = sub(pos(p), cm);
    const u = sub([p.vx, p.vy, p.vz], vm);
    ang = addScaled(ang, cross(r, u), p.m);
    const r2 = dot(r, r);
    inertia[0] += p.m * (r2 - r[0] * r[0]);
    inertia[4] += p.m * (r2 - r[1] * r[1]);
    inertia[8] += p.m * (r2 - r[2] * r[2]);
    inertia[1] -= p.m * r[0] * r[1];
    inertia[2] -= p.m * r[0] * r[2];
    inertia[5] -= p.m * r[1] * r[2];
  }
  [inertia[3], inertia[6], inertia[7]] = [inertia[1], inertia[2], inertia[5]];
  const omega = mulMV(inverse3(inertia) ?? [0, 0, 0, 0, 0, 0, 0, 0, 0], ang);
  for (const p of ps) {
    const rigid = addScaled(vm, cross(omega, sub(pos(p), cm)), 1);
    p.vx = rigid[0] + (p.vx - rigid[0]) * keep;
    p.vy = rigid[1] + (p.vy - rigid[1]) * keep;
    p.vz = rigid[2] + (p.vz - rigid[2]) * keep;
  }
};

/** Scale `p`'s velocity relative to `anchor`'s by `keep`. */
const dampToward = (p: Particle, anchor: Particle, keep: number) => {
  p.vx = anchor.vx + (p.vx - anchor.vx) * keep;
  p.vy = anchor.vy + (p.vy - anchor.vy) * keep;
  p.vz = anchor.vz + (p.vz - anchor.vz) * keep;
};

// ---- the rig -----------------------------------------------------------------

const STEP = 1 / 60;
const SUBSTEPS = 8;
const GRAVITY = 9.81;
const BASE_R = 0.24;
const RIM_R = 0.33;
const PINCH_MAX = 0.13;
/**
 * Grab attachment compliance (inverse stiffness). The body's is soft, so a hard pull stretches it
 * before the pot comes along; the pot is held like the solid thing it is.
 */
const GRAB_COMPLIANCE = { body: 1.5e-3, green: 1.5e-3, pot: 1e-4 };
/** How quickly the hand catches up with the pointer (rad/s; ~0.1 s to arrive). */
const HAND_OMEGA = 28;
const JOINT_COMPLIANCE = 2e-4;
/**
 * The rim joints are near-vertical struts, so on their own they let the body slide sideways over
 * the pot like a parallelogram, with no first-order resistance — a shove would slosh the face down
 * into the rim. Diagonals to the neighbouring rim points triangulate it.
 */
const BRACE_COMPLIANCE = 1.4e-2;
/**
 * Spokes from the body's centre to its surface particles. Shape matching alone pulls each particle
 * with a force proportional to its own (small) mass, so a one-particle grab could stretch the body
 * forever without lifting anything; the spokes give it an absolute stiffness, which is what lets
 * a hard enough pull lift the pot.
 */
const SPOKE_COMPLIANCE = 9e-3;
const FRICTION_STATIC = 0.8;
const FRICTION_KINETIC = 0.6;
const RESTITUTION = 0.28;
const MAX_SPEED = 12;
/** How far the plant may roam toward the camera and away from it. */
const Z_NEAR = 0.5;
const Z_FAR = -0.7;

interface Grip {
  p: Particle;
  weight: number;
  offset: Vec3;
  compliance: number;
}

export class PlantRig {
  /** 0 (mochi) … 1 (vinyl). */
  firmness = 0.5;
  /** Extra damping for prefers-reduced-motion. */
  calm = false;
  /** Half-width of the visible table at the plant's depth; the screen edges are walls. */
  halfWidth = 1.4;

  private readonly base: Particle[];
  /** Rim particles: +x, +z, −x, −z. */
  private readonly rim: Particle[];
  private readonly socket: Particle;
  private readonly pot: Particle[];
  /** Body particles: centre, then +x, −x, +y, −y, +z, −z. */
  private readonly body: Particle[];
  private readonly centre: Particle;
  private readonly top: Particle;
  private readonly stem: Particle;
  private readonly leaves: [Particle, Particle, Particle];
  private readonly plant: Particle[];
  private readonly all: Particle[];
  private readonly joints: [Particle, Particle][];
  private readonly braces: [Particle, Particle][];

  /** The rigid pot; the body alone (fitted for drawing); body and rim together (simulated). */
  private readonly potCl: Cluster;
  private readonly bodyCl: Cluster;
  private readonly fleshCl: Cluster;

  private pose: PlantPose;
  private potRot: Mat3 = IDENTITY;
  private potC: Vec3 = [0, 0, 0];
  private bodyA: Mat3 = IDENTITY;
  private bodyRot: Mat3 = IDENTITY;
  private bodyC: Vec3 = [0, 0, 0];

  private grips: Grip[] = [];
  /**
   * The hand: it chases the pointer (`aim`) on a critically damped spring at the physics rate, so
   * pointer events arriving in bursts — a slow device, a flick — can't land as impulses.
   */
  private target: Vec3 | null = null;
  private aim: Vec3 = [0, 0, 0];
  private handV: Vec3 = [0, 0, 0];
  /** From the dragged point out to the surface that was actually touched. */
  private surface: Vec3 = [0, 0, 0];
  private held: PlantPart | null = null;
  private pinchC: Vec3 = [0, 0, 0];
  private pinchD: Vec3 = [0, 0, 0];
  private pinchV: Vec3 = [0, 0, 0];
  private acc = 0;
  private airTime = 0;
  private fallSpeed = 0;
  private landed = 0;

  constructor(pose: PlantPose) {
    this.pose = pose;
    this.base = Array.from({ length: 8 }, () => particle(0.3, 0, 1));
    const rimPx = particle(0.1, 0.04, 1);
    const rimPz = particle(0.1, 0.04, 1);
    const rimNx = particle(0.1, 0.04, 1);
    const rimNz = particle(0.1, 0.04, 1);
    this.rim = [rimPx, rimPz, rimNx, rimNz];
    this.socket = particle(0.2, 0, 1);
    this.pot = [...this.base, ...this.rim, this.socket];
    const centre = particle(0.12, 0.07, 0.6);
    const px = particle(0.06, 0.05, 0.6);
    const nx = particle(0.06, 0.05, 0.6);
    const py = particle(0.06, 0.05, 0.6);
    const ny = particle(0.06, 0.05, 0.6);
    const pz = particle(0.06, 0.05, 0.6);
    const nz = particle(0.06, 0.05, 0.6);
    this.body = [centre, px, nx, py, ny, pz, nz];
    this.centre = centre;
    this.top = py;
    this.stem = particle(0.03, 0.04, 0);
    this.leaves = [particle(0.012, 0.03, 0), particle(0.012, 0.03, 0), particle(0.012, 0.03, 0)];
    this.plant = [...this.pot, ...this.body];
    this.all = [...this.plant, this.stem, ...this.leaves];
    this.potCl = cluster(this.pot);
    this.bodyCl = cluster(this.body);
    // Sharing the rim makes bending the body against its pot a deformation like any other: it
    // is resisted by the same stiffness, and the reaction tips the pot.
    this.fleshCl = cluster([...this.body, ...this.rim]);
    // The body is planted: its sides hang off the rim, its base and centre off a socket inside.
    this.joints = [
      [px, rimPx],
      [pz, rimPz],
      [nx, rimNx],
      [nz, rimNz],
      [ny, this.socket],
      [centre, this.socket],
    ];
    this.braces = [
      [px, rimPz],
      [px, rimNz],
      [pz, rimPx],
      [pz, rimNx],
      [nx, rimPz],
      [nx, rimNz],
      [nz, rimPx],
      [nz, rimNx],
    ];
    this.setPose(pose);
    this.reset();
  }

  /** Put every particle back at rest, still. */
  reset() {
    for (const p of this.all) {
      [p.x, p.y, p.z] = restPos(p);
      [p.px, p.py, p.pz] = restPos(p);
      p.vx = 0;
      p.vy = 0;
      p.vz = 0;
    }
    for (const cl of [this.potCl, this.bodyCl, this.fleshCl]) {
      cl.turn = [0, 0, 0, 1];
    }
    this.release();
    this.pinchD = [0, 0, 0];
    this.pinchV = [0, 0, 0];
    this.fitPot();
    this.fitBody(4);
  }

  /** Rest proportions follow the morph; the particles catch up through the physics. */
  setPose(pose: PlantPose) {
    this.pose = pose;
    const { bodyR, squash, stemH, leaves } = pose;
    for (const [i, p] of this.base.entries()) {
      const a = (i / this.base.length) * Math.PI * 2;
      setRest(p, [Math.cos(a) * BASE_R, 0, Math.sin(a) * BASE_R]);
    }
    for (const [i, p] of this.rim.entries()) {
      const a = (i / this.rim.length) * Math.PI * 2;
      setRest(p, [Math.cos(a) * RIM_R, POT_H, Math.sin(a) * RIM_R]);
    }
    setRest(this.socket, [0, POT_H * 0.65, 0]);
    const cy = bodyCentreY(pose);
    const r: Vec3 = [bodyR * 0.8, bodyR * squash * 0.8, bodyR * 0.8];
    const offsets: Vec3[] = [
      [0, 0, 0],
      [r[0], 0, 0],
      [-r[0], 0, 0],
      [0, r[1], 0],
      [0, -r[1], 0],
      [0, 0, r[2]],
      [0, 0, -r[2]],
    ];
    for (const [i, p] of this.body.entries()) {
      const o = offsets[i] ?? [0, 0, 0];
      setRest(p, [o[0], cy + o[1], o[2]]);
    }
    const top = bodyTop(pose);
    setRest(this.stem, [0, cy + top + stemH, 0]);
    const base: Vec3 = [0, cy + top - 0.02 + stemH * 0.92, 0];
    for (const [i, p] of this.leaves.entries()) {
      const leaf = leaves[i];
      if (leaf) {
        setRest(
          p,
          addScaled(base, mulMV(transpose(leafRestFrame(leaf)), [0, 1, 0]), leafReach(leaf)),
        );
      }
    }
    restCluster(this.potCl);
    restCluster(this.bodyCl);
    restCluster(this.fleshCl);
  }

  /** Off the floor for more than a beat. */
  get airborne() {
    return this.airTime > 0.12;
  }

  /** How hard the last landing was (0–1); reading it clears it. */
  takeLanding() {
    const v = this.landed;
    this.landed = 0;
    return v;
  }

  /** How far the grabbed spot is being pulled out of shape, ~0–1. */
  get strain() {
    return clamp(length(this.pinchD) / PINCH_MAX, 0, 1);
  }

  /**
   * Take hold of `part` where the pointer hit it, and return the point to drag. For the pot and
   * body that is the hit pushed back to the part's own depth: the plant is only ever seen from
   * the front, so a pull in the screen plane shouldn't also swing it toward the camera.
   */
  grab(part: PlantPart, hit: Vec3): Vec3 {
    this.release();
    this.held = part;
    this.target = hit;
    this.aim = hit;
    this.handV = [0, 0, 0];
    this.surface = [0, 0, 0];
    const single = this.leafOrStem(part);
    if (single) {
      const compliance = GRAB_COMPLIANCE.green;
      this.grips = [{ compliance, offset: sub(pos(single), hit), p: single, weight: 1 }];
      return hit;
    }
    const group = part === "pot" ? this.pot : this.body;
    const radius = part === "pot" ? 0.45 : 0.32;
    const compliance = part === "pot" ? GRAB_COMPLIANCE.pot : GRAB_COMPLIANCE.body;
    const anchor: Vec3 = [hit[0], hit[1], centroid(group)[2]];
    this.target = anchor;
    this.aim = anchor;
    this.surface = sub(hit, anchor);
    let nearest: Grip | null = null;
    let best = Infinity;
    for (const p of group) {
      const offset = sub(pos(p), anchor);
      const d = length(sub(pos(p), hit));
      const grip = { compliance, offset, p, weight: smoothstep(radius, 0, d) };
      if (grip.weight > 0) {
        this.grips.push(grip);
      }
      if (d < best) {
        best = d;
        nearest = grip;
      }
    }
    if (nearest) {
      nearest.weight = 1;
      if (!this.grips.includes(nearest)) {
        this.grips.push(nearest);
      }
    }
    if (part === "body") {
      this.pinchC = this.toDesign(hit);
    }
    return anchor;
  }

  drag(target: Vec3) {
    this.aim = [target[0], Math.max(target[1], 0.03), target[2]];
  }

  release() {
    this.grips = [];
    this.target = null;
    this.held = null;
  }

  /** A tap: push in where it was touched, and let it wobble back. */
  boop(part: PlantPart, hit: Vec3, dir: Vec3) {
    const single = this.leafOrStem(part);
    if (single) {
      kick(single, dir, 3);
      return;
    }
    const group = part === "pot" ? this.pot : this.body;
    for (const p of group) {
      kick(p, dir, (part === "pot" ? 0.9 : 1.6) * smoothstep(0.5, 0, length(sub(pos(p), hit))));
    }
    if (part === "body") {
      this.pinchC = this.toDesign(hit);
      this.pinchV = addScaled(this.pinchV, mulMV(this.bodyLinear(), dir), 2.4);
      for (const leaf of this.leaves) {
        kick(leaf, [0, 1, 0], 1.2);
      }
    }
  }

  /** A little jump with a twist — for switching characters. */
  hop(strength: number) {
    const c = centroid(this.all);
    const spin = (Math.random() < 0.5 ? -1 : 1) * strength * 0.5;
    for (const p of this.all) {
      p.vy += strength;
      p.vx -= spin * (p.y - c[1]);
      p.vy += spin * (p.x - c[0]);
    }
  }

  step(dt: number) {
    this.acc = Math.min(this.acc + dt, STEP * 3);
    while (this.acc >= STEP) {
      this.acc -= STEP;
      const h = STEP / SUBSTEPS;
      for (let s = 0; s < SUBSTEPS; s += 1) {
        this.substep(h);
      }
      this.damp(STEP);
      this.track(STEP);
    }
    this.stepPinch(dt);
    if (!this.all.every((p) => Number.isFinite(p.x + p.y + p.z))) {
      this.reset();
    }
  }

  /** Fit the render transforms to the particles. */
  frame(): RigFrame {
    const { pose } = this;
    const potM = transpose(this.potRot);
    const bodyM = this.bodyLinear();
    const cy = bodyCentreY(pose);
    const bc: Vec3 = [0, cy, 0];
    const ainv = inverse3(this.bodyA) ?? IDENTITY;
    const bodyK = addScaled(
      mulMV(pose.gaze, sub(sub(this.bodyCl.restC, mulMV(ainv, this.bodyC)), bc)),
      bc,
      1,
    );
    const toQ = (p: Particle) => sub(addScaled(mulMV(bodyM, pos(p)), bodyK, 1), bc);
    const top = bodyTop(pose);
    const stemTip = toQ(this.stem);
    const leafBase = addScaled([0, top - 0.02, 0], sub(stemTip, [0, top, 0]), 0.92);
    const leafM = this.leaves.map((p, i) => {
      const leaf = pose.leaves[i];
      if (!leaf) {
        return IDENTITY;
      }
      const rest = leafRestFrame(leaf);
      const swing = rotBetween([rest[3], rest[4], rest[5]], normalize(sub(toQ(p), leafBase)));
      return mulMM(rest, transpose(swing));
    });
    const [l0 = IDENTITY, l1 = IDENTITY, l2 = IDENTITY] = leafM;
    return {
      bodyK,
      bodyM,
      bound: this.bound(),
      gap: this.gap(bodyM, bodyK, potM),
      head: this.bodyC,
      leafBase,
      leafM: [l0, l1, l2],
      pinchC: this.pinchC,
      pinchD: this.pinchD,
      potK: sub(this.potCl.restC, mulMV(potM, this.potC)),
      potM,
      shadow: [this.potC[0], this.potC[2], Math.min(...this.base.map((p) => p.y))],
      stemTip,
      warp: Math.max(1, spectralNorm(bodyM)),
    };
  }

  /** Raymarch the CPU copy of the SDF; the nearest part at the hit is what gets grabbed. */
  pick(ro: Vec3, rd: Vec3, frame: RigFrame): PlantHit | null {
    const [bx, by, bz, br] = frame.bound;
    const oc = sub(ro, [bx, by, bz]);
    const b = dot(oc, rd);
    const disc = b * b - (dot(oc, oc) - br * br);
    if (disc <= 0) {
      return null;
    }
    let t = Math.max(-b - Math.sqrt(disc), 0);
    const tEnd = -b + Math.sqrt(disc);
    for (let i = 0; i < 128 && t < tEnd; i += 1) {
      const p = addScaled(ro, rd, t);
      const { d, part } = this.sample(p, frame);
      if (d < 0.002) {
        return { part, point: p };
      }
      t += d * 0.9;
    }
    return null;
  }

  // ---- solver -----------------------------------------------------------------

  private substep(h: number) {
    for (const p of this.all) {
      p.ivy = p.vy;
      p.vy -= GRAVITY * p.grav * h;
      p.px = p.x;
      p.py = p.y;
      p.pz = p.z;
      p.x += p.vx * h;
      p.y += p.vy * h;
      p.z += p.vz * h;
    }
    this.solveGrips(h);
    this.solveBody(h);
    const spoke = (SPOKE_COMPLIANCE * (1.6 - this.firmness)) / (h * h);
    for (const p of this.body) {
      if (p !== this.centre) {
        solveDistance(this.centre, p, length(sub(restPos(p), restPos(this.centre))), spoke, false);
      }
    }
    const joint = JOINT_COMPLIANCE / (h * h);
    for (const [a, b] of this.joints) {
      solveDistance(a, b, length(sub(restPos(a), restPos(b))), joint, false);
    }
    const brace = (BRACE_COMPLIANCE * (1.6 - this.firmness)) / (h * h);
    for (const [a, b] of this.braces) {
      solveDistance(a, b, length(sub(restPos(a), restPos(b))), brace, false);
    }
    this.solveGreen(h);
    this.solvePot(h);
    for (const p of this.all) {
      this.collide(p);
    }
    for (const p of this.all) {
      this.settle(p, h);
    }
  }

  private solveGrips(h: number) {
    if (!this.target) {
      return;
    }
    const toAim = sub(this.aim, this.target);
    this.handV = addScaled(scale(this.handV, 1 - 2 * HAND_OMEGA * h), toAim, HAND_OMEGA ** 2 * h);
    const target = addScaled(this.target, this.handV, h);
    this.target = target;
    for (const { p, weight, offset, compliance } of this.grips) {
      const goal = addScaled(target, offset, 1);
      goal[1] = Math.max(goal[1], p.rad);
      nudge(p, sub(goal, pos(p)), (weight * p.w) / (p.w + compliance / (h * h)));
    }
  }

  /**
   * Affine shape matching of body + rim, volume-normalised: stretch it one way and it thins the
   * others, press it and it bulges. Then refit the body alone, which is what gets drawn.
   */
  private solveBody(h: number) {
    const { apq, c } = measure(this.fleshCl);
    const r = faceForward(extractRotation(apq, this.fleshCl.turn, 2));
    const a = mulMM(apq, this.fleshCl.aqqInv);
    const det = det3(a);
    const vol = det > 0.05 ? scaleM(a, 1 / Math.cbrt(det)) : r;
    const g = lerpM(r, vol, 0.5 - this.firmness * 0.3);
    const k = Math.min(1, 120 * 9 ** this.firmness * h * h);
    for (const { p, q } of this.fleshCl.members) {
      nudge(p, sub(addScaled(mulMV(g, q), c, 1), pos(p)), k);
    }
    this.fitBody(2);
  }

  /**
   * Stem and leaves chase their posed spots on springs. Each spot hangs off the particle it grows
   * from by its rest offset — turned with the body and gaze, never stretched — so a lean or a
   * stretch can't swing the goal further than the body itself moved (that loop gain above 1 is
   * what used to launch the plant). Held by a leaf, the held chain goes limp and ropes carry the
   * pull down the stem: a spring there would be an engine hauling the plant up its own rope.
   */
  private solveGreen(h: number) {
    const { pose } = this;
    const held = this.held === null ? null : this.leafOrStem(this.held);
    const base: Vec3 = [0, bodyCentreY(pose) + bodyTop(pose), 0];
    const turn = mulMM(this.bodyRot, transpose(pose.gaze));
    const hang = (anchor: Vec3, from: Vec3, s: Vec3) =>
      addScaled(anchor, mulMV(turn, sub(s, from)), 1);
    const stemRest = restPos(this.stem);
    if (held === null) {
      const stemGoal = hang(this.headPoint(base), base, stemRest);
      nudge(this.stem, sub(stemGoal, pos(this.stem)), Math.min(1, 700 * h * h));
    }
    for (const leaf of this.leaves) {
      if (leaf !== held) {
        const goal = hang(pos(this.stem), stemRest, restPos(leaf));
        nudge(leaf, sub(goal, pos(leaf)), Math.min(1, 260 * h * h));
      }
    }
    if (held === null) {
      return;
    }
    if (held !== this.stem) {
      solveDistance(this.stem, held, length(sub(restPos(held), stemRest)) * 1.1 + 0.01, 0, true);
    }
    solveDistance(this.top, this.stem, length(sub(stemRest, restPos(this.top))) * 1.35, 0, true);
  }

  /** The pot is rigid; left tipped over, it slowly rights itself like a weighted toy. */
  private solvePot(h: number) {
    const { apq, c } = measure(this.potCl);
    let r = faceForward(extractRotation(apq, this.potCl.turn, 3));
    const up: Vec3 = [r[1], r[4], r[7]];
    const tilt = Math.acos(clamp(up[1], -1, 1));
    if (this.held === null && tilt > 0.5) {
      const axis: Vec3 = [-up[2], 0, up[0]];
      const turn = Math.min(tilt - 0.5, 40 * tilt * h * h);
      r = mulMM(rotAxis(normalize(axis), turn), r);
    }
    for (const { p, q } of this.potCl.members) {
      [p.x, p.y, p.z] = addScaled(mulMV(r, q), c, 1);
    }
    this.potRot = r;
    this.potC = c;
  }

  private collide(p: Particle) {
    if (p.y < p.rad) {
      const pen = p.rad - p.y;
      p.y = p.rad;
      const dx = p.x - p.px;
      const dz = p.z - p.pz;
      const slide = Math.hypot(dx, dz);
      const keep =
        slide < FRICTION_STATIC * pen ? 0 : Math.max(0, 1 - (FRICTION_KINETIC * pen) / slide);
      p.x = p.px + dx * keep;
      p.z = p.pz + dz * keep;
    }
    p.x = clamp(p.x, -this.halfWidth, this.halfWidth);
    p.z = clamp(p.z, Z_FAR, Z_NEAR);
  }

  private settle(p: Particle, h: number) {
    let vx = (p.x - p.px) / h;
    let vy = (p.y - p.py) / h;
    let vz = (p.z - p.pz) / h;
    const speed = Math.hypot(vx, vy, vz);
    if (speed > MAX_SPEED) {
      const s = MAX_SPEED / speed;
      vx *= s;
      vy *= s;
      vz *= s;
    }
    if (p.ivy < -1.5 && p.y <= p.rad + 1e-4 && this.held === null) {
      vy = Math.max(vy, -p.ivy * RESTITUTION);
    }
    p.vx = vx;
    p.vy = vy;
    p.vz = vz;
  }

  /**
   * Bleed the body's deformation (velocity minus its best rigid motion) so wobbles settle, and
   * the stem and leaves relative to what they hang from. Falling, swinging and sliding are left
   * alone.
   */
  private damp(dt: number) {
    const calm = this.calm ? 2.2 : 1;
    const air = Math.exp(-0.25 * dt);
    for (const p of this.all) {
      p.vx *= air;
      p.vy *= air;
      p.vz *= air;
    }
    dampDeformation(this.body, Math.exp(-4.5 * calm * dt));
    // The body rocking on its joints is a rigid motion of the body alone, so it is damped as
    // deformation of pot + body together.
    dampDeformation(this.plant, Math.exp(-2.2 * calm * dt));
    dampToward(this.stem, this.centre, Math.exp(-3 * calm * dt));
    for (const leaf of this.leaves) {
      dampToward(leaf, this.stem, Math.exp(-2.6 * calm * dt));
    }
    if (this.calm) {
      dampDeformation(this.all, Math.exp(-3 * dt));
    }
  }

  private track(dt: number) {
    const low = Math.min(...this.base.map((p) => p.y));
    const vy = this.base.reduce((s, p) => s + p.vy, 0) / this.base.length;
    if (low > 0.004) {
      this.airTime += dt;
      this.fallSpeed = Math.max(this.fallSpeed, -vy);
      return;
    }
    if (this.airTime > 0.12) {
      this.landed = Math.max(this.landed, clamp(this.fallSpeed / 4, 0, 1));
    }
    this.airTime = 0;
    this.fallSpeed = 0;
  }

  /** The pinch chases whatever offset the body itself couldn't follow, on a springy return. */
  private stepPinch(dt: number) {
    let goal: Vec3 = [0, 0, 0];
    if (this.held === "body" && this.target) {
      const spot = this.headPoint(this.pinchC);
      const reach = addScaled(this.target, this.surface, 1);
      goal = clampLength(mulMV(this.bodyLinear(), sub(reach, spot)), PINCH_MAX);
    }
    const omega = 24;
    const zeta = this.calm ? 0.9 : 0.3;
    const n = Math.ceil(dt / (1 / 240));
    const h = dt / n;
    for (let i = 0; i < n; i += 1) {
      const acc = addScaled(
        scale(sub(goal, this.pinchD), omega * omega),
        this.pinchV,
        -2 * zeta * omega,
      );
      this.pinchV = addScaled(this.pinchV, acc, h);
      this.pinchD = clampLength(addScaled(this.pinchD, this.pinchV, h), PINCH_MAX * 1.4);
    }
  }

  // ---- frames -------------------------------------------------------------------

  private fitPot() {
    const { apq, c } = measure(this.potCl);
    this.potC = c;
    this.potRot = extractRotation(apq, this.potCl.turn, 4);
  }

  private fitBody(iterations: number) {
    const { apq, c } = measure(this.bodyCl);
    this.bodyC = c;
    this.bodyA = mulMM(apq, this.bodyCl.aqqInv);
    this.bodyRot = extractRotation(apq, this.bodyCl.turn, iterations);
  }

  /** World → design space, linear part: the body's inverse affine, then the gaze. */
  private bodyLinear() {
    return mulMM(this.pose.gaze, inverse3(this.bodyA) ?? IDENTITY);
  }

  /** A design-space point (stem, leaves, the pinched spot) carried out into the world. */
  private headPoint(s: Vec3): Vec3 {
    const bc: Vec3 = [0, bodyCentreY(this.pose), 0];
    const rest = addScaled(mulMV(transpose(this.pose.gaze), sub(s, bc)), bc, 1);
    return addScaled(mulMV(this.bodyA, sub(rest, this.bodyCl.restC)), this.bodyC, 1);
  }

  private toDesign(p: Vec3): Vec3 {
    const bc: Vec3 = [0, bodyCentreY(this.pose), 0];
    const ainv = inverse3(this.bodyA) ?? IDENTITY;
    const rest = addScaled(mulMV(ainv, sub(p, this.bodyC)), this.bodyCl.restC, 1);
    return addScaled(mulMV(this.pose.gaze, sub(rest, bc)), bc, 1);
  }

  private leafOrStem(part: PlantPart) {
    switch (part) {
      case "stem": {
        return this.stem;
      }
      case "leaf0": {
        return this.leaves[0];
      }
      case "leaf1": {
        return this.leaves[1];
      }
      case "leaf2": {
        return this.leaves[2];
      }
      default: {
        return null;
      }
    }
  }

  /** How far the body's frame and the pot's disagree at the rim, design space. */
  private gap(bodyM: Mat3, bodyK: Vec3, potM: Mat3) {
    const potK = sub(this.potCl.restC, mulMV(potM, this.potC));
    let gap = 0;
    for (const p of this.rim) {
      const viaBody = addScaled(mulMV(bodyM, pos(p)), bodyK, 1);
      const viaPot = addScaled(mulMV(potM, pos(p)), potK, 1);
      gap = Math.max(gap, length(sub(viaBody, viaPot)));
    }
    return gap;
  }

  private bound(): [number, number, number, number] {
    const span = (axis: (p: Particle) => number) => {
      const vs = this.all.map(axis);
      return [Math.min(...vs), Math.max(...vs)] as const;
    };
    const [x0, x1] = span((p) => p.x);
    const [y0, y1] = span((p) => p.y);
    const [z0, z1] = span((p) => p.z);
    const reach = Math.hypot(x1 - x0, y1 - y0, z1 - z0) / 2;
    return [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, reach + 0.34 + length(this.pinchD)];
  }

  /** The scene SDF on the CPU, mirroring the shader (minus breathing and the pinch). */
  private sample(p: Vec3, frame: RigFrame) {
    const { pose } = this;
    const xp = addScaled(mulMV(frame.potM, p), frame.potK, 1);
    const xb = addScaled(mulMV(frame.bodyM, p), frame.bodyK, 1);
    const t = clamp((xp[1] - BLEND_LO) / (BLEND_HI - BLEND_LO), 0, 1);
    const q = sub(addScaled(xp, sub(xb, xp), t * t * (3 - 2 * t)), [0, bodyCentreY(pose), 0]);
    const k = 1 / (frame.warp * (1 + (6 * t * (1 - t) * frame.gap) / (BLEND_HI - BLEND_LO)));
    const rb = pose.bodyR;
    const top = bodyTop(pose);
    const pot = sdPot(xp);
    const body = sdEllipsoid(q, [rb, rb * pose.squash, rb]) * k;
    const parts: [PlantPart, number][] = [
      ["pot", pot],
      ["body", body],
    ];
    const stem = sdCapsule(q, [0, top, 0], frame.stemTip, 0.035);
    const knob = length(sub(q, frame.stemTip)) - clamp(pose.stemH * 10, 0, 1) * 0.05;
    let green = smin(stem, knob, 0.03) * k;
    parts.push(["stem", green]);
    for (const [i, leaf] of pose.leaves.entries()) {
      const lq = mulMV(frame.leafM[i] ?? IDENTITY, sub(q, frame.leafBase));
      const ll = leaf.len * leaf.s;
      const ww = leaf.wid * leaf.s;
      const r: Vec3 = [ww, ll * 0.6, ww * 0.45];
      const d = sdEllipsoid([lq[0], lq[1] - (ll * 0.55 + 0.02), lq[2]], r) * k;
      green = Math.min(green, d);
      parts.push([LEAF_PARTS[i] ?? "stem", d]);
    }
    const aq: Vec3 = [Math.abs(q[0]) - rb * 0.92, q[1] + rb * pose.squash * 0.05, q[2]];
    const armS = Math.max(pose.armS, 0.001);
    const arm = sdCapsule(aq, [0, 0, 0], [0, armS * 0.26, 0], armS * 0.095) * k;
    parts.push(["body", arm]);
    const d = Math.min(pot, smin(smin(body, green, 0.045), arm, 0.07));
    let best: [PlantPart, number] = ["pot", Infinity];
    for (const part of parts) {
      if (part[1] < best[1]) {
        best = part;
      }
    }
    return { d, part: best[0] };
  }
}
/preview.tsx
"use client";

import { useState } from "react";

import type { ChibiVariant } from "./chibi-plants";
import { CHIBI_VARIANTS, ChibiPlants, VARIANTS } from "./chibi-plants";

const Preview = () => {
  const [variant, setVariant] = useState<ChibiVariant>("pip");

  return (
    <main className="relative h-dvh w-full overflow-hidden bg-[#0b0a0e]">
      <ChibiPlants variant={variant} className="absolute inset-0" />

      <fieldset className="absolute bottom-4 left-4 m-0 flex min-w-0 flex-col items-center border-0 p-0">
        <legend className="sr-only">Plant</legend>
        {CHIBI_VARIANTS.map((v) => (
          <button
            key={v}
            type="button"
            data-variant={v}
            aria-label={VARIANTS[v].label}
            aria-pressed={variant === v}
            title={VARIANTS[v].label}
            onClick={() => setVariant(v)}
            className="group grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            <span
              className={`size-4 rounded-full transition-[scale,opacity,box-shadow] duration-200 ease-out motion-reduce:transition-none ${
                variant === v
                  ? "ring-2 ring-white/85 ring-offset-2 ring-offset-[#0b0a0e]"
                  : "opacity-55 group-hover:scale-110 group-hover:opacity-100"
              }`}
              style={{ background: VARIANTS[v].palette.body }}
            />
          </button>
        ))}
      </fieldset>
    </main>
  );
};

export default Preview;

More from uicapsule

Browse all components in this library →